From 14418750964a9803a1f9fda1d266c36f65bb02f1 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 1 Oct 2026 18:24:12 +0100 Subject: [PATCH] Unify per-monitor workspace numbering and lifecycle policy --- compositor/src/input.rs | 35 +- compositor/src/ipc.rs | 17 +- compositor/src/overview.rs | 22 +- compositor/src/render/overview.rs | 2 +- compositor/src/shell_control.rs | 43 ++- compositor/src/state.rs | 4 +- compositor/src/state/animation.rs | 2 +- compositor/src/state/layout.rs | 12 +- compositor/src/state/navigation.rs | 34 +- compositor/src/state/outputs.rs | 24 +- compositor/src/state/windows.rs | 11 +- crates/ferese-core/src/lib.rs | 3 +- crates/ferese-core/src/output.rs | 60 ++- crates/ferese-core/src/workspace.rs | 213 ++++------- crates/ferese-core/src/workspace_policy.rs | 352 ++++++++++++++++++ docs/configuration.md | 24 +- protocols/ferese-shell-v1.xml | 5 +- .../tests/test_workspace_history_isolated.py | 73 +++- shell/ferese-shell/src/bar.rs | 23 +- shell/ferese-shell/src/control.rs | 64 +++- 20 files changed, 707 insertions(+), 316 deletions(-) create mode 100644 crates/ferese-core/src/workspace_policy.rs diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 6a8775b..ae9f134 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -781,39 +781,18 @@ impl Ferese { } } - pub(crate) fn relative_workspace_target(&mut self, next: bool) -> Option { + pub(crate) fn relative_workspace_target(&self, next: bool) -> Option { let output = self.output_workspaces.focused_output()?; let current = self.output_workspaces.active_workspace(output)?; - let mut candidates = self.workspaces.ordered(); - candidates.retain(|workspace| { - self.output_workspaces - .output_for_workspace(workspace.id) - .is_none_or(|owner| owner == output) - }); - let index = candidates.iter().position(|workspace| workspace.id == current)?; + let candidates = self.output_workspaces.ordered_workspaces(&self.workspaces, output); + let index = candidates.iter().position(|id| *id == current)?; let neighbor = if next { - index.checked_add(1) + index.checked_add(1)? } else { - index.checked_sub(1) + index.checked_sub(1)? }; - if let Some(workspace) = neighbor.and_then(|index| candidates.get(index)) { - return Some(workspace.id); - } - if next - && self.workspaces.workspace(current).is_some_and(|workspace| { - workspace.layout.window_ids().next().is_some() || !workspace.floating.is_empty() - }) - { - let number = self - .workspaces - .iter() - .filter_map(|workspace| workspace.name.parse::().ok()) - .max() - .unwrap_or(1) - .checked_add(1)?; - return self.workspaces.ensure_numeric(number).ok(); - } - None + + candidates.get(neighbor).copied() } fn focus_window_at(&mut self, position: Point, serial: Serial, raise: bool) { diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index bb5f979..cf18865 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -987,20 +987,25 @@ impl Ferese { } fn workspaces_json(&self) -> Value { - let active = self.workspaces.active_id(); let workspaces = self - .workspaces - .ordered() + .output_workspaces + .workspace_views(&self.workspaces) .into_iter() - .map(|workspace| { + .map(|view| { + let workspace = self.workspaces.workspace(view.id).expect("workspace view exists"); let mode = match workspace.layout.mode() { LayoutMode::Scrolling => "scrolling", LayoutMode::Tree => "tree", }; json!({ "id": workspace.id.0, - "name": workspace.name, - "active": workspace.id == active, + "name": view.index.to_string(), + "index": view.index, + "output": view.output.0, + "window_count": view.window_count, + "visible": view.visible, + "focused": view.focused, + "active": view.focused, "layout": mode, "focused_window": workspace.last_focused.map(|window| window.0), "fullscreen_window": workspace.fullscreen.map(|window| window.0), diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index 1221662..89bc921 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -125,6 +125,7 @@ impl Default for OverviewState { #[derive(Clone, Debug)] pub(crate) struct WorkspaceCard { pub workspace: WorkspaceId, + pub index: u32, pub rect: Rect, pub selected: bool, pub windows: Vec<(WindowId, Rect)>, @@ -459,12 +460,11 @@ impl Ferese { pub(crate) fn overview_workspace_label( &mut self, - workspace: WorkspaceId, + index: u32, scale: f64, ) -> Option<(MemoryRenderBuffer, smithay::utils::Size)> { - let name = self.workspaces.workspace(workspace)?.name.clone(); self.overview - .label(&name, scale, self.theme_settings.text_primary_color.0) + .label(&index.to_string(), scale, self.theme_settings.text_primary_color.0) } pub(crate) fn set_overview_active(&mut self, active: bool) { @@ -606,7 +606,12 @@ impl Ferese { let selected = self.output_workspaces.active_workspace(output_id); let strip = workspace_strip(bounds); let capacity = strip_capacity(strip); - let workspaces = self.workspaces.ordered(); + let workspaces = self + .output_workspaces + .workspace_views(&self.workspaces) + .into_iter() + .filter(|view| view.output == output_id) + .collect::>(); let selected_index = workspaces .iter() .position(|workspace| Some(workspace.id) == selected) @@ -629,7 +634,9 @@ impl Ferese { .skip(offset) .take(capacity) .enumerate() - .map(|(index, workspace)| { + .map(|(index, view)| { + let workspace = self.workspaces.workspace(view.id).expect("workspace view exists"); + let rect = Rect::new( start_x + index as f64 * (card_width + WORKSPACE_CARD_GAP), strip.y + 12.0, @@ -659,6 +666,7 @@ impl Ferese { let windows = preview_layout(preview_bounds, &windows, 4.0, 4.0).into_iter().collect(); WorkspaceCard { workspace: workspace.id, + index: view.index, rect, selected: Some(workspace.id) == selected, windows, @@ -721,10 +729,10 @@ impl Ferese { }; let capacity = strip_capacity(workspace_strip(bounds)); let cards = self.overview_workspace_cards(&output); - let ordered = self.workspaces.ordered(); + let ordered = self.output_workspaces.ordered_workspaces(&self.workspaces, output_id); let current = cards .first() - .and_then(|card| ordered.iter().position(|workspace| workspace.id == card.workspace)) + .and_then(|card| ordered.iter().position(|id| *id == card.workspace)) .unwrap_or(0); let next = if delta > 0.0 { current.saturating_add(1) diff --git a/compositor/src/render/overview.rs b/compositor/src/render/overview.rs index 72fd8a5..354cb65 100644 --- a/compositor/src/render/overview.rs +++ b/compositor/src/render/overview.rs @@ -237,7 +237,7 @@ pub(super) fn overview_strip_elements( } } } - if let Some((buffer, size)) = state.overview_workspace_label(card.workspace, scale) { + if let Some((buffer, size)) = state.overview_workspace_label(card.index, scale) { let location = Point::::from(( ((card.rect.x - f64::from(output_geometry.loc.x) + card.rect.width * 0.5) * scale).round() as i32 - size.w / 2, diff --git a/compositor/src/shell_control.rs b/compositor/src/shell_control.rs index 7876879..4a2b711 100644 --- a/compositor/src/shell_control.rs +++ b/compositor/src/shell_control.rs @@ -318,20 +318,17 @@ impl Ferese { } fn workspace_snapshots(&self) -> Vec { - self.workspaces - .ordered() + self.output_workspaces + .workspace_views(&self.workspaces) .into_iter() - .map(|workspace| { - let output = self.output_workspaces.output_for_workspace(workspace.id); - let active = - output.is_some_and(|output| self.output_workspaces.active_workspace(output) == Some(workspace.id)); - - WorkspaceSnapshot { - id: workspace.id, - output, - name: workspace.name.clone(), - active, - } + .map(|view| WorkspaceSnapshot { + id: view.id, + output: Some(view.output), + name: view.index.to_string(), + index: view.index, + window_count: view.window_count as u32, + visible: view.visible, + focused: view.focused, }) .collect() } @@ -456,7 +453,10 @@ impl ShellSnapshot { output_hi, output_lo, workspace.name.clone(), - u32::from(workspace.active), + workspace.index, + workspace.window_count, + u32::from(workspace.visible), + u32::from(workspace.focused), ); } for snapshot in &self.windows { @@ -499,7 +499,10 @@ struct WorkspaceSnapshot { id: WorkspaceId, output: Option, name: String, - active: bool, + index: u32, + window_count: u32, + visible: bool, + focused: bool, } fn split_id(id: u64) -> (u32, u32) { @@ -559,7 +562,10 @@ mod tests { id: WorkspaceId(1), output: Some(OutputId(1)), name: "1".into(), - active: true, + index: 1, + window_count: 1, + visible: true, + focused: true, }], windows: vec![ManagedWindowSnapshot { id: WindowId(1), @@ -575,7 +581,10 @@ mod tests { |s| s.outputs[0].active_workspace = WorkspaceId(2), |s| s.outputs.clear(), |s| s.workspaces[0].output = None, - |s| s.workspaces[0].active = false, + |s| s.workspaces[0].visible = false, + |s| s.workspaces[0].focused = false, + |s| s.workspaces[0].index = 2, + |s| s.workspaces[0].window_count = 0, |s| s.workspaces[0].name = "renamed".into(), |s| s.windows[0].workspace = 2, |s| s.windows[0].title = "new title".into(), diff --git a/compositor/src/state.rs b/compositor/src/state.rs index a249b99..8d185b9 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -1016,7 +1016,7 @@ mod tests { let mut workspaces = WorkspaceSet::default(); let first = workspaces.active_id(); workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); - let second = workspaces.ensure_numeric(2).unwrap(); + let second = workspaces.create_workspace(); workspaces.activate(second).unwrap(); workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(3), Axis::Horizontal, 0.5).unwrap(); @@ -1027,7 +1027,7 @@ mod tests { .connect(OutputId(1), OutputGeometry::new(0, 0, 1920, 1080), first) .unwrap(); let owner = if assignment == Some(OutputId(2)) { - let third = workspaces.ensure_numeric(3).unwrap(); + let third = workspaces.create_workspace(); outputs .connect(OutputId(2), OutputGeometry::new(1920, 0, 1920, 1080), third) .unwrap(); diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs index c66d24d..7dc2dd5 100644 --- a/compositor/src/state/animation.rs +++ b/compositor/src/state/animation.rs @@ -313,7 +313,7 @@ impl Ferese { }) .collect::>(); self.unmap_invisible_windows(&visible_windows); - self.prune_empty_workspaces(&visible); + self.reconcile_workspaces(&visible); self.send_shell_snapshots(); active_animation = true; } diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index b1eabd3..51e9896 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -15,11 +15,11 @@ impl Ferese { visible } - pub(super) fn prune_empty_workspaces(&mut self, visible: &HashSet) { - let mut protected = visible.clone(); - protected.extend(self.output_workspaces.history_workspaces()); - for workspace in self.workspaces.prune_empty(&protected) { - self.output_workspaces.forget_workspace(workspace); + pub(super) fn reconcile_workspaces(&mut self, visible: &HashSet) { + for workspace in self + .output_workspaces + .reconcile_workspaces(&mut self.workspaces, visible) + { self.viewport_animations.remove(&workspace); } } @@ -52,7 +52,7 @@ impl Ferese { pub fn relayout(&mut self) { self.refresh_input_capture_zones(); let visible_workspaces = self.visible_workspace_ids(); - self.prune_empty_workspaces(&visible_workspaces); + self.reconcile_workspaces(&visible_workspaces); if self.session_lock.active { self.configure_lock_surfaces(); } diff --git a/compositor/src/state/navigation.rs b/compositor/src/state/navigation.rs index e3dad4e..b34e634 100644 --- a/compositor/src/state/navigation.rs +++ b/compositor/src/state/navigation.rs @@ -394,16 +394,13 @@ impl Ferese { slide_direction: Option, auto_back_and_forth: bool, ) { - let workspace = match self.workspaces.ensure_numeric(index) { - Ok(workspace) => workspace, - Err(error) => { - tracing::error!(%error, index, "failed to switch workspace"); - return; - } - }; let Some(output) = self.output_workspaces.focused_output() else { return; }; + let Some(workspace) = self.output_workspaces.workspace_at(&self.workspaces, output, index) else { + return; + }; + let previous = self.output_workspaces.active_workspace(output); let owner = match self .output_workspaces @@ -526,26 +523,23 @@ impl Ferese { let Some(window) = self.focused_window else { return; }; + let Some(output) = self.output_workspaces.focused_output() else { + return; + }; + let Some(destination) = self.output_workspaces.workspace_at(&self.workspaces, output, index) else { + return; + }; let axis = self .output_bounds() .and_then(|bounds| { - let workspace = self.workspaces.ensure_numeric(index).ok()?; - let target = self.workspaces.workspace(workspace)?; + let target = self.workspaces.workspace(destination)?; target.layout.automatic_axis(target.last_focused, bounds).ok() }) .unwrap_or(Axis::Horizontal); - let destination = match self.workspaces.move_window_to_numeric(window, index, axis, 0.5) { - Ok(destination) => destination, - Err(error) => { - tracing::error!(%error, ?window, index, "failed to move window to workspace"); - return; - } - }; - if let Some(output) = self.output_workspaces.focused_output() - && let Err(error) = self.output_workspaces.assign_workspace(output, destination) - { - tracing::error!(%error, ?destination, "failed to assign destination workspace"); + if let Err(error) = self.workspaces.move_window_to_workspace(window, destination, axis, 0.5) { + tracing::error!(%error, ?window, index, "failed to move window to workspace"); + return; } self.focused_window = self.workspaces.active().last_focused; diff --git a/compositor/src/state/outputs.rs b/compositor/src/state/outputs.rs index fa7f069..649d786 100644 --- a/compositor/src/state/outputs.rs +++ b/compositor/src/state/outputs.rs @@ -15,25 +15,23 @@ impl Ferese { self.next_output_id = self.next_output_id.saturating_add(1); id }); - let fallback_workspace = (1..) - .find_map(|index| { - let workspace = self.workspaces.ensure_numeric(index).ok()?; - self.output_workspaces - .output_for_workspace(workspace) - .is_none() - .then_some(workspace) - }) - .expect("numeric workspaces are inexhaustible"); + let fallback_workspace = self + .workspaces + .iter() + .filter(|workspace| self.output_workspaces.output_for_workspace(workspace.id).is_none()) + .map(|workspace| workspace.id) + .min_by_key(|id| id.0) + .unwrap_or_else(|| self.workspaces.create_workspace()); let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h); let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace); match registration { - Ok(workspace) => { + Ok(_) => { self.output_ids.insert(output.clone(), output_id); - if self.output_workspaces.focused_output() == Some(output_id) { - self.activate_output_workspace(output_id, workspace); - } + self.restore_output_focus(); + let visible = self.visible_workspace_ids(); + self.reconcile_workspaces(&visible); self.send_shell_snapshots(); } Err(error) => { diff --git a/compositor/src/state/windows.rs b/compositor/src/state/windows.rs index ebf1bd3..d6ec3eb 100644 --- a/compositor/src/state/windows.rs +++ b/compositor/src/state/windows.rs @@ -287,10 +287,15 @@ impl Ferese { .automatic_axis(self.focused_window, bounds) .unwrap_or(Axis::Horizontal); - if let Some(workspace) = rule.workspace - && let Err(error) = self.workspaces.move_window_to_numeric(id, workspace, axis, 0.5) + if let Some(index) = rule.workspace + && let Some(output) = self + .workspaces + .workspace_for_window(id) + .and_then(|workspace| self.output_workspaces.output_for_workspace(workspace)) + && let Some(workspace) = self.output_workspaces.workspace_at(&self.workspaces, output, index) + && let Err(error) = self.workspaces.move_window_to_workspace(id, workspace, axis, 0.5) { - tracing::warn!(%error, ?id, workspace, "failed to apply window workspace rule"); + tracing::warn!(%error, ?id, ?workspace, "failed to apply window workspace rule"); } let bounds = self.floating_bounds_for_window(id).unwrap_or(bounds); diff --git a/crates/ferese-core/src/lib.rs b/crates/ferese-core/src/lib.rs index 426ab22..0d562dd 100644 --- a/crates/ferese-core/src/lib.rs +++ b/crates/ferese-core/src/lib.rs @@ -3,10 +3,11 @@ mod focus; pub mod notifications; mod output; mod workspace; +mod workspace_policy; pub use focus::{FocusCycle, FocusHistory}; pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch}; pub use workspace::{ LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet, - workspace_order_key, }; +pub use workspace_policy::WorkspaceView; diff --git a/crates/ferese-core/src/output.rs b/crates/ferese-core/src/output.rs index e556662..067c46e 100644 --- a/crates/ferese-core/src/output.rs +++ b/crates/ferese-core/src/output.rs @@ -107,10 +107,6 @@ impl OutputWorkspaceMap { self.outputs.get(&output).and_then(|state| state.previous) } - pub fn history_workspaces(&self) -> impl Iterator + '_ { - self.outputs.values().filter_map(|state| state.previous) - } - pub fn output_for_workspace(&self, workspace: WorkspaceId) -> Option { self.assignments.get(&workspace).copied() } @@ -171,6 +167,19 @@ impl OutputWorkspaceMap { let revision_matches = self.revision(evacuated.workspace) == evacuated.revision; if assignment_matches && revision_matches { + // Cleanup may have removed every other workspace while this + // monitor was disconnected. Never strand the donor output; + // the reconnecting monitor can use its fresh fallback. + if evacuated + .target + .and_then(|target| self.outputs.get(&target)) + .is_some_and(|state| { + state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace) + }) + { + continue; + } + if let Some(target) = evacuated.target && let Some(target_state) = self.outputs.get_mut(&target) { @@ -413,6 +422,22 @@ impl OutputWorkspaceMap { #[cfg(test)] mod tests { + #[test] + fn reconnect_cannot_reclaim_the_donor_monitors_only_workspace() { + let mut outputs = OutputWorkspaceMap::default(); + outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap(); + outputs.connect(OutputId(2), geometry(1920), WorkspaceId(2)).unwrap(); + outputs.focus_output(OutputId(2)).unwrap(); + outputs.disconnect(OutputId(2)).unwrap(); + assert!(outputs.forget_workspace(WorkspaceId(1))); + + outputs.connect(OutputId(2), geometry(1920), WorkspaceId(3)).unwrap(); + + assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(2))); + assert_eq!(outputs.active_workspace(OutputId(2)), Some(WorkspaceId(3))); + assert!(outputs.validate()); + } + #[test] fn forgetting_an_inactive_workspace_prevents_hotplug_resurrection() { let mut outputs = super::OutputWorkspaceMap::default(); @@ -530,33 +555,6 @@ mod tests { ); } - #[test] - fn empty_previous_workspace_survives_pruning_until_history_moves_on() { - let mut workspaces = crate::WorkspaceSet::default(); - let first = workspaces.active_id(); - let second = workspaces.ensure_numeric(2).unwrap(); - let third = workspaces.ensure_numeric(3).unwrap(); - let mut outputs = OutputWorkspaceMap::default(); - let display = OutputId(1); - outputs.connect(display, geometry(0), first).unwrap(); - outputs.switch_workspace(display, second).unwrap(); - workspaces.activate(second).unwrap(); - let protected = outputs.history_workspaces().chain([second]).collect(); - let removed = workspaces.prune_empty(&protected); - assert!(removed.contains(&third)); - assert!(workspaces.workspace(first).is_some()); - let third = workspaces.ensure_numeric(3).unwrap(); - outputs.switch_workspace(display, third).unwrap(); - workspaces.activate(third).unwrap(); - let protected = outputs.history_workspaces().chain([third]).collect(); - for workspace in workspaces.prune_empty(&protected) { - outputs.forget_workspace(workspace); - } - assert!(workspaces.workspace(first).is_none()); - assert!(workspaces.workspace(second).is_some()); - assert_eq!(outputs.previous_workspace(display), Some(second)); - } - #[test] fn new_outputs_receive_distinct_workspaces() { let mut outputs = OutputWorkspaceMap::default(); diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index bf4f8d3..51e2216 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -10,14 +10,9 @@ use ferese_layout::{ #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct WorkspaceId(pub u64); -pub fn workspace_order_key(name: &str, id: WorkspaceId) -> (u64, u64) { - (name.parse::().unwrap_or(u64::MAX), id.0) -} - #[derive(Debug)] pub struct Workspace { pub id: WorkspaceId, - pub name: String, pub layout: WorkspaceLayout, pub floating: Vec, pub last_focused: Option, @@ -339,7 +334,6 @@ impl From for WorkspaceError { pub struct WorkspaceSet { active: WorkspaceId, workspaces: HashMap, - names: HashMap, window_workspaces: HashMap, placements: HashMap, default_layout_mode: LayoutMode, @@ -436,19 +430,16 @@ impl WorkspaceSet { let active = WorkspaceId(1); let workspace = Workspace { id: active, - name: "1".to_owned(), layout: WorkspaceLayout::new(default_layout_mode, default_column_width, scrolling_focus_strategy), floating: Vec::new(), last_focused: None, fullscreen: None, }; let workspaces = HashMap::from([(active, workspace)]); - let names = HashMap::from([("1".to_owned(), active)]); Self { active, workspaces, - names, window_workspaces: HashMap::new(), placements: HashMap::new(), default_layout_mode, @@ -500,36 +491,6 @@ impl WorkspaceSet { self.workspaces.values() } - pub fn ordered(&self) -> Vec<&Workspace> { - let mut workspaces = self.iter().collect::>(); - workspaces.sort_unstable_by_key(|workspace| workspace_order_key(&workspace.name, workspace.id)); - workspaces - } - - 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() } @@ -680,25 +641,39 @@ impl WorkspaceSet { Ok(changed) } - pub fn ensure_numeric(&mut self, index: u32) -> Result { + pub fn remove_empty(&mut self, id: WorkspaceId) -> bool { + if id == self.active || !self.workspace(id).is_some_and(Workspace::is_empty) { + return false; + } + + self.workspaces.remove(&id); + true + } + + #[cfg(test)] + pub(crate) fn fixture_workspace(&mut self, index: u32) -> Result { if index == 0 { return Err(WorkspaceError::InvalidNumericName(index)); } - let name = index.to_string(); - - if let Some(id) = self.names.get(&name) { - return Ok(*id); + while self.next_id <= u64::from(index) { + self.create_workspace(); } + let id = WorkspaceId(u64::from(index)); + self.workspace(id) + .map(|_| id) + .ok_or(WorkspaceError::UnknownWorkspace(id)) + } + + /// Creates an identity; display positions are assigned by the output owner. + pub fn create_workspace(&mut self) -> WorkspaceId { let id = WorkspaceId(self.next_id); - self.next_id += 1; - self.names.insert(name.clone(), id); + self.next_id = self.next_id.checked_add(1).expect("workspace IDs exhausted"); self.workspaces.insert( id, Workspace { id, - name, layout: WorkspaceLayout::new( self.default_layout_mode, self.default_column_width, @@ -711,11 +686,12 @@ impl WorkspaceSet { ); debug_assert!(self.validate().is_ok()); - Ok(id) + id } - pub fn switch_to_numeric(&mut self, index: u32) -> Result, WorkspaceError> { - let workspace = self.ensure_numeric(index)?; + #[cfg(test)] + fn switch_to_fixture(&mut self, index: u32) -> Result, WorkspaceError> { + let workspace = self.fixture_workspace(index)?; self.activate(workspace) } @@ -798,17 +774,32 @@ impl WorkspaceSet { Ok(()) } - pub fn move_window_to_numeric( + #[cfg(test)] + fn move_window_to_fixture( &mut self, window: WindowId, index: u32, axis: Axis, ratio: f64, ) -> Result { + let destination = self.fixture_workspace(index)?; + self.move_window_to_workspace(window, destination, axis, ratio) + } + + pub fn move_window_to_workspace( + &mut self, + window: WindowId, + destination_id: WorkspaceId, + axis: Axis, + ratio: f64, + ) -> Result { + if self.workspace(destination_id).is_none() { + return Err(WorkspaceError::UnknownWorkspace(destination_id)); + } + let source_id = self .workspace_for_window(window) .ok_or(LayoutError::UnknownWindow(window))?; - let destination_id = self.ensure_numeric(index)?; if source_id == destination_id { return Ok(destination_id); @@ -943,14 +934,11 @@ impl WorkspaceSet { if !self.workspaces.contains_key(&self.active) { return Err(WorkspaceError::InvalidState("active workspace is missing")); } - if self.names.len() != self.workspaces.len() { - return Err(WorkspaceError::InvalidState("workspace name index is inconsistent")); - } let mut seen_windows = HashSet::new(); for (id, workspace) in &self.workspaces { - if workspace.id != *id || self.names.get(&workspace.name) != Some(id) { + if workspace.id != *id { return Err(WorkspaceError::InvalidState("workspace index is inconsistent")); } @@ -1033,70 +1021,6 @@ fn finite_or_zero(value: f64) -> f64 { #[cfg(test)] mod tests { - #[test] - fn numeric_order_survives_pruning_and_recreation() { - let mut set = super::WorkspaceSet::default(); - let original_first = set.active_id(); - let second = set.ensure_numeric(2).unwrap(); - set.activate(second).unwrap(); - assert_eq!(set.prune_empty(&std::collections::HashSet::new()), vec![original_first]); - - let tenth = set.ensure_numeric(10).unwrap(); - let first = set.ensure_numeric(1).unwrap(); - assert_ne!(first, original_first); - assert!(first.0 > tenth.0); - assert_eq!( - set.ordered() - .into_iter() - .map(|workspace| (workspace.name.as_str(), workspace.id)) - .collect::>(), - vec![("1", first), ("2", second), ("10", tenth)] - ); - set.activate(first).unwrap(); - assert_eq!(set.active().name, "1"); - assert!(set.validate().is_ok()); - } - - #[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(); @@ -1149,21 +1073,22 @@ mod tests { assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5)); assert_eq!(layout.default_width(), ColumnWidth::Full); assert_eq!(workspaces.workspace_for_window(WindowId(1)), Some(workspace)); - workspaces.switch_to_numeric(2).unwrap(); + workspaces.switch_to_fixture(2).unwrap(); assert_eq!(workspaces.active().layout.mode(), LayoutMode::Tree); assert_eq!(workspaces.workspace_for_window(WindowId(1)), Some(workspace)); } use super::*; #[test] - fn numeric_workspaces_are_created_lazily_with_stable_ids() { + fn workspace_creation_uses_fresh_ids() { let mut workspaces = WorkspaceSet::default(); let first = workspaces.active_id(); - let ninth = workspaces.ensure_numeric(9).unwrap(); + let second = workspaces.create_workspace(); + assert!(workspaces.remove_empty(second)); - assert_eq!(workspaces.ensure_numeric(1), Ok(first)); - assert_eq!(workspaces.ensure_numeric(9), Ok(ninth)); - assert_ne!(first, ninth); + let third = workspaces.create_workspace(); + assert_ne!(first, third); + assert_ne!(second, third); assert!(workspaces.validate().is_ok()); } @@ -1184,7 +1109,7 @@ mod tests { .unwrap(); assert_eq!(first.geometry[&WindowId(1)], Rect::new(4.0, 4.0, 992.0, 792.0)); - workspaces.switch_to_numeric(2).unwrap(); + workspaces.switch_to_fixture(2).unwrap(); assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling); } @@ -1193,11 +1118,11 @@ mod tests { let mut workspaces = WorkspaceSet::default(); workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); - assert_eq!(workspaces.switch_to_numeric(2).unwrap(), None); + assert_eq!(workspaces.switch_to_fixture(2).unwrap(), None); workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap(); - assert_eq!(workspaces.switch_to_numeric(1).unwrap(), Some(WindowId(1))); - assert_eq!(workspaces.switch_to_numeric(2).unwrap(), Some(WindowId(2))); + assert_eq!(workspaces.switch_to_fixture(1).unwrap(), Some(WindowId(1))); + assert_eq!(workspaces.switch_to_fixture(2).unwrap(), Some(WindowId(2))); assert!(workspaces.validate().is_ok()); } @@ -1208,7 +1133,7 @@ mod tests { workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap(); let first = workspaces.active_id(); let second = workspaces - .move_window_to_numeric(WindowId(2), 2, Axis::Horizontal, 0.5) + .move_window_to_fixture(WindowId(2), 2, Axis::Horizontal, 0.5) .unwrap(); assert_eq!(workspaces.active_id(), first); @@ -1247,7 +1172,7 @@ mod tests { let mut workspaces = WorkspaceSet::default(); workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); workspaces - .move_window_to_numeric(WindowId(1), 2, Axis::Horizontal, 0.5) + .move_window_to_fixture(WindowId(1), 2, Axis::Horizontal, 0.5) .unwrap(); assert_eq!( @@ -1402,7 +1327,7 @@ mod tests { let mut workspaces = WorkspaceSet::default(); workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); let parent_workspace = workspaces.active_id(); - workspaces.switch_to_numeric(2).unwrap(); + workspaces.switch_to_fixture(2).unwrap(); workspaces .insert_floating_window( WindowId(2), @@ -1463,7 +1388,7 @@ mod tests { assert_eq!(workspaces.active().last_focused, Some(WindowId(2))); assert!(workspaces.focus_window(WindowId(2)).is_ok()); - workspaces.switch_to_numeric(2).unwrap(); + workspaces.switch_to_fixture(2).unwrap(); assert_eq!(workspaces.activate(workspace).unwrap(), Some(WindowId(2))); assert_eq!(workspaces.active().fullscreen, Some(WindowId(1))); assert!(workspaces.validate().is_ok()); @@ -1474,13 +1399,13 @@ mod tests { let mut workspaces = WorkspaceSet::default(); workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); workspaces.toggle_fullscreen(WindowId(1)).unwrap(); - workspaces.switch_to_numeric(2).unwrap(); + workspaces.switch_to_fixture(2).unwrap(); workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap(); workspaces - .move_window_to_numeric(WindowId(2), 1, Axis::Horizontal, 0.5) + .move_window_to_fixture(WindowId(2), 1, Axis::Horizontal, 0.5) .unwrap(); - assert_eq!(workspaces.switch_to_numeric(1).unwrap(), Some(WindowId(1))); + assert_eq!(workspaces.switch_to_fixture(1).unwrap(), Some(WindowId(1))); assert_eq!(workspaces.active().last_focused, Some(WindowId(1))); assert_eq!( workspaces.focus_window(WindowId(2)), @@ -1586,13 +1511,13 @@ mod tests { } 2 => { workspaces - .switch_to_numeric((random.rotate_left(11) % 4 + 1) as u32) + .switch_to_fixture((random.rotate_left(11) % 4 + 1) as u32) .unwrap(); } 3 if !windows.is_empty() => { let window = windows[random as usize % windows.len()]; workspaces - .move_window_to_numeric( + .move_window_to_fixture( window, (random.rotate_left(17) % 4 + 1) as u32, random_axis(random), @@ -1651,3 +1576,13 @@ mod tests { ) } } + +impl Workspace { + pub fn window_count(&self) -> usize { + self.layout.window_ids().count() + self.floating.len() + } + + pub fn is_empty(&self) -> bool { + self.layout.window_ids().next().is_none() && self.floating.is_empty() + } +} diff --git a/crates/ferese-core/src/workspace_policy.rs b/crates/ferese-core/src/workspace_policy.rs new file mode 100644 index 0000000..6d5c000 --- /dev/null +++ b/crates/ferese-core/src/workspace_policy.rs @@ -0,0 +1,352 @@ +use std::collections::HashSet; + +use crate::{OutputId, OutputWorkspaceMap, WorkspaceId, WorkspaceSet}; + +/// Shared presentation model. Identity never changes when positions compact. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorkspaceView { + pub id: WorkspaceId, + pub output: OutputId, + pub index: u32, + pub window_count: usize, + pub visible: bool, + pub focused: bool, +} + +impl OutputWorkspaceMap { + pub fn workspace_views(&self, workspaces: &WorkspaceSet) -> Vec { + let mut outputs = self.connected_outputs().collect::>(); + outputs.sort_unstable(); + + outputs + .into_iter() + .flat_map(|output| { + self.ordered_workspaces(workspaces, output) + .into_iter() + .enumerate() + .map(move |(index, id)| { + let visible = self.active_workspace(output) == Some(id); + WorkspaceView { + id, + output, + index: index as u32 + 1, + window_count: workspaces.workspace(id).unwrap().window_count(), + visible, + focused: visible && self.focused_output() == Some(output), + } + }) + }) + .collect() + } + + pub fn ordered_workspaces(&self, workspaces: &WorkspaceSet, output: OutputId) -> Vec { + let mut ids = workspaces + .iter() + .filter(|workspace| self.output_for_workspace(workspace.id) == Some(output)) + .map(|workspace| workspace.id) + .collect::>(); + ids.sort_unstable_by_key(|id| id.0); + ids + } + + /// Out-of-range numeric selection resolves to the trailing spare. Looking + /// up a gesture target never creates workspaces or changes history. + pub fn workspace_at(&self, workspaces: &WorkspaceSet, output: OutputId, index: u32) -> Option { + let index = index.checked_sub(1)? as usize; + let ids = self.ordered_workspaces(workspaces, output); + ids.get(index).or_else(|| ids.last()).copied() + } + + /// Reconcile after membership, selection, output, or transition changes. + /// History is a reference, not a reason to keep an empty workspace alive. + pub fn reconcile_workspaces( + &mut self, + workspaces: &mut WorkspaceSet, + transitions: &HashSet, + ) -> Vec { + if let Some(active) = self.focused_output().and_then(|output| self.active_workspace(output)) { + workspaces.activate(active).expect("output's active workspace exists"); + } + + let mut protected = transitions.clone(); + let outputs = self.connected_outputs().collect::>(); + for output in outputs { + let ordered = self.ordered_workspaces(workspaces, output); + if let Some(active) = self.active_workspace(output) { + protected.insert(active); + } + + let spare = ordered + .last() + .copied() + .filter(|id| workspaces.workspace(*id).is_some_and(|w| w.is_empty())) + .unwrap_or_else(|| { + let id = workspaces.create_workspace(); + self.assign_workspace(output, id).expect("connected output"); + id + }); + protected.insert(spare); + } + + let removed = workspaces + .iter() + .filter(|workspace| { + workspace.is_empty() && !protected.contains(&workspace.id) && workspace.id != workspaces.active_id() + }) + .map(|workspace| workspace.id) + .collect::>(); + for id in &removed { + self.forget_workspace(*id); + assert!(workspaces.remove_empty(*id)); + } + + removed + } +} + +#[cfg(test)] +mod tests { + use ferese_layout::{Axis, Rect, WindowId}; + + use super::*; + use crate::OutputGeometry; + + fn setup() -> (WorkspaceSet, OutputWorkspaceMap, OutputId) { + let set = WorkspaceSet::default(); + let mut outputs = OutputWorkspaceMap::default(); + let output = OutputId(1); + outputs + .connect(output, OutputGeometry::new(0, 0, 1000, 800), set.active_id()) + .unwrap(); + + (set, outputs, output) + } + + fn reconcile(set: &mut WorkspaceSet, outputs: &mut OutputWorkspaceMap) { + outputs.reconcile_workspaces(set, &HashSet::new()); + assert!(set.validate().is_ok()); + + for output in outputs.connected_outputs() { + let ids = outputs.ordered_workspaces(set, output); + assert!(set.workspace(*ids.last().unwrap()).unwrap().is_empty()); + let empties = ids.iter().filter(|id| set.workspace(**id).unwrap().is_empty()).count(); + let active_middle_empty = outputs + .active_workspace(output) + .is_some_and(|id| Some(&id) != ids.last() && set.workspace(id).unwrap().is_empty()); + assert_eq!(empties, 1 + usize::from(active_middle_empty)); + } + } + + #[test] + fn occupied_workspaces_get_one_spare_and_positions_compact_without_changing_ids() { + let (mut set, mut outputs, output) = setup(); + let first = set.active_id(); + reconcile(&mut set, &mut outputs); + assert_eq!(set.iter().count(), 1); + set.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); + reconcile(&mut set, &mut outputs); + let second = outputs.workspace_at(&set, output, 2).unwrap(); + outputs.switch_workspace(output, second).unwrap(); + reconcile(&mut set, &mut outputs); + set.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap(); + reconcile(&mut set, &mut outputs); + let spare = outputs.workspace_at(&set, output, 3).unwrap(); + + set.remove_window(WindowId(1)).unwrap(); + reconcile(&mut set, &mut outputs); + + assert!(set.workspace(first).is_none()); + assert_eq!(outputs.previous_workspace(output), None); + assert_eq!(outputs.workspace_at(&set, output, 1), Some(second)); + assert_eq!(outputs.workspace_at(&set, output, 99), Some(spare)); + assert_eq!(outputs.workspace_at(&set, output, 0), None); + assert_eq!( + outputs + .workspace_views(&set) + .iter() + .map(|view| view.index) + .collect::>(), + vec![1, 2] + ); + } + + #[test] + fn selected_empty_survives_until_leaving_but_history_does_not_pin_it() { + let (mut set, mut outputs, output) = setup(); + let first = set.active_id(); + set.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); + reconcile(&mut set, &mut outputs); + let spare = outputs.workspace_at(&set, output, 2).unwrap(); + set.remove_window(WindowId(1)).unwrap(); + reconcile(&mut set, &mut outputs); + assert!(set.workspace(first).is_some()); + + outputs.switch_workspace(output, spare).unwrap(); + reconcile(&mut set, &mut outputs); + + assert_eq!(outputs.ordered_workspaces(&set, output), vec![spare]); + assert_eq!(outputs.previous_workspace(output), None); + } + + #[test] + fn transitions_temporarily_protect_empty_workspaces_and_cancelled_lookup_has_no_effect() { + let (mut set, mut outputs, output) = setup(); + let first = set.active_id(); + set.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); + reconcile(&mut set, &mut outputs); + let spare = outputs.workspace_at(&set, output, 2).unwrap(); + let before = outputs.workspace_views(&set); + for _ in 0..10 { + assert_eq!(outputs.workspace_at(&set, output, 2), Some(spare)); + } + assert_eq!(outputs.workspace_views(&set), before); + assert_eq!(outputs.previous_workspace(output), None); + + outputs.switch_workspace(output, spare).unwrap(); + set.remove_window(WindowId(1)).unwrap(); + outputs.reconcile_workspaces(&mut set, &HashSet::from([first])); + assert!(set.workspace(first).is_some()); + reconcile(&mut set, &mut outputs); + assert!(set.workspace(first).is_none()); + } + + #[test] + fn floating_windows_count_as_occupied_and_last_close_preserves_active_identity() { + let (mut set, mut outputs, output) = setup(); + let first = set.active_id(); + set.insert_floating_window(WindowId(1), first, Rect::new(0., 0., 100., 100.), true) + .unwrap(); + reconcile(&mut set, &mut outputs); + assert_eq!(outputs.workspace_views(&set)[0].window_count, 1); + assert_eq!(set.iter().count(), 2); + + set.remove_window(WindowId(1)).unwrap(); + reconcile(&mut set, &mut outputs); + assert_eq!(outputs.active_workspace(output), Some(first)); + let spare = outputs.workspace_at(&set, output, 2).unwrap(); + outputs.switch_workspace(output, spare).unwrap(); + reconcile(&mut set, &mut outputs); + assert_eq!(set.iter().count(), 1); + } + + #[test] + fn monitors_have_independent_positions_and_reconnect_restores_occupied_workspaces() { + let (mut set, mut outputs, left) = setup(); + let first = set.active_id(); + set.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); + reconcile(&mut set, &mut outputs); + let right = OutputId(2); + let second = set.create_workspace(); + outputs + .connect(right, OutputGeometry::new(1000, 0, 1000, 800), second) + .unwrap(); + outputs.focus_output(right).unwrap(); + reconcile(&mut set, &mut outputs); + set.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap(); + reconcile(&mut set, &mut outputs); + assert_eq!(outputs.workspace_at(&set, left, 1), Some(first)); + assert_eq!(outputs.workspace_at(&set, right, 1), Some(second)); + assert_eq!( + outputs.workspace_views(&set).iter().filter(|view| view.visible).count(), + 2 + ); + assert_eq!( + outputs.workspace_views(&set).iter().filter(|view| view.focused).count(), + 1 + ); + + outputs.disconnect(right).unwrap(); + reconcile(&mut set, &mut outputs); + let fallback = set.create_workspace(); + outputs + .connect(right, OutputGeometry::new(1000, 0, 1000, 800), fallback) + .unwrap(); + reconcile(&mut set, &mut outputs); + + assert_eq!(outputs.output_for_workspace(first), Some(left)); + assert_eq!(outputs.output_for_workspace(second), Some(right)); + assert!(set.workspace(fallback).is_none()); + assert_eq!(outputs.workspace_at(&set, right, 1), Some(second)); + } +} + +#[cfg(test)] +mod sequences { + use ferese_layout::{Axis, WindowId}; + + use super::*; + use crate::OutputGeometry; + + #[test] + fn repeated_switch_move_close_and_hotplug_keep_workspace_invariants() { + let mut set = WorkspaceSet::default(); + let mut outputs = OutputWorkspaceMap::default(); + outputs + .connect(OutputId(1), OutputGeometry::new(0, 0, 1000, 800), set.active_id()) + .unwrap(); + let mut next_window = 1; + let mut seed = 0x1234_5678_u64; + + for step in 0..500 { + outputs.reconcile_workspaces(&mut set, &HashSet::new()); + let output = outputs.focused_output().unwrap(); + let windows = set + .iter() + .flat_map(|workspace| workspace.layout.window_ids()) + .collect::>(); + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + + match seed % 5 { + 0 => { + set.insert_window(WindowId(next_window), Axis::Horizontal, 0.5).unwrap(); + next_window += 1; + } + 1 if !windows.is_empty() => { + set.remove_window(windows[seed as usize % windows.len()]).unwrap(); + } + 2 => { + let index = (seed % 9 + 1) as u32; + let target = outputs.workspace_at(&set, output, index).unwrap(); + outputs.switch_workspace(output, target).unwrap(); + } + 3 if !windows.is_empty() => { + let target = outputs.workspace_at(&set, output, u32::MAX).unwrap(); + set.move_window_to_workspace(windows[seed as usize % windows.len()], target, Axis::Horizontal, 0.5) + .unwrap(); + } + _ => { + if outputs.active_workspace(OutputId(2)).is_some() { + outputs.disconnect(OutputId(2)).unwrap(); + } else { + let fallback = set.create_workspace(); + outputs + .connect(OutputId(2), OutputGeometry::new(1000, 0, 1000, 800), fallback) + .unwrap(); + outputs.focus_output(OutputId(2)).unwrap(); + } + } + } + + outputs.reconcile_workspaces(&mut set, &HashSet::new()); + assert!(set.validate().is_ok(), "step {step}"); + let views = outputs.workspace_views(&set); + assert_eq!(views.iter().filter(|view| view.focused).count(), 1); + assert_eq!(views.len(), set.iter().count()); + for output in outputs.connected_outputs() { + let rows = views.iter().filter(|view| view.output == output).collect::>(); + assert_eq!(rows.last().unwrap().window_count, 0); + for (index, row) in rows.iter().enumerate() { + assert_eq!(row.index, index as u32 + 1); + if row.window_count == 0 && index + 1 != rows.len() { + assert!(row.visible, "abandoned empty workspace at step {step}"); + } + } + if let Some(previous) = outputs.previous_workspace(output) { + assert!(rows.iter().any(|row| row.id == previous)); + } + } + } + } +} diff --git a/docs/configuration.md b/docs/configuration.md index dc8a278..6e9178c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -221,18 +221,20 @@ theme { ## Workspaces -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 when the current workspace contains windows. Empty workspaces are -cleaned up automatically, keeping at most one spare empty workspace in addition to -visible workspaces and each monitor's previous workspace. Additional displays -receive their own workspace. +Each monitor has an independent list of workspaces, numbered from 1. Its bar, +overview strip, and numeric shortcuts use that same order. Numbers change when +empty workspaces are removed; internal workspace IDs stay stable. -`Super+Escape` returns to the previous workspace on the focused monitor. Repeat -it to switch between the two most recently visited workspaces. Switching to a -workspace on another monitor updates that monitor's history. History is cleared -for a disconnected monitor; it does not jump to a workspace reclaimed by another -monitor on reconnect. With no previous workspace, the action does nothing. +One empty workspace is always available at the end of each monitor's list. +Opening or moving a window there creates another spare. An empty workspace stays +while displayed or participating in a swipe, then disappears after you leave it. +Selecting a number beyond the current list selects the trailing empty workspace. +Window-rule workspace numbers also refer to positions on the window's monitor. + +`Super+Escape` returns to the previous surviving workspace on the focused monitor. +History does not keep abandoned empty workspaces alive. With no previous workspace, +the action does nothing. Disconnecting a monitor clears its history; occupied +workspaces move to another monitor and can return when it reconnects. Enable **Settings → Windows → Toggle back with the same workspace shortcut**, or: diff --git a/protocols/ferese-shell-v1.xml b/protocols/ferese-shell-v1.xml index 2aafe3c..b93154e 100644 --- a/protocols/ferese-shell-v1.xml +++ b/protocols/ferese-shell-v1.xml @@ -110,7 +110,10 @@ - + + + + diff --git a/scripts/tests/test_workspace_history_isolated.py b/scripts/tests/test_workspace_history_isolated.py index 04a5ac0..14825d2 100644 --- a/scripts/tests/test_workspace_history_isolated.py +++ b/scripts/tests/test_workspace_history_isolated.py @@ -1,7 +1,7 @@ """Workspace history against a temporary nested compositor. FERESE_TEST_WORKSPACE_HISTORY=1 python3 scripts/tests/test_workspace_history_isolated.py -Requires target/debug/ferese, feresectl, dbus-daemon, and a Wayland host. +Requires target/debug/ferese, feresectl, foot, dbus-daemon, and a Wayland host. """ import json import os @@ -15,7 +15,7 @@ import unittest @unittest.skipUnless(os.environ.get("FERESE_TEST_WORKSPACE_HISTORY") == "1", "nested workspace test is opt-in") class WorkspaceHistoryTest(unittest.TestCase): - def test_empty_workspace_history_and_explicit_ipc_selection(self): + def test_dynamic_positions_spare_cleanup_and_history(self): repo = Path(__file__).resolve().parents[2] with tempfile.TemporaryDirectory(prefix="ferese-workspace-test-") as temporary: root = Path(temporary) @@ -59,28 +59,73 @@ class WorkspaceHistoryTest(unittest.TestCase): if compositor.poll() is not None or time.monotonic() >= deadline: self.fail((root / "compositor.log").read_text()) time.sleep(.025) - self.assertEqual(active(), "1") - command("workspace-back-and-forth") - self.assertEqual(active(), "1", "no history is a no-op") + def wait_for(predicate): + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + value = predicate() + if value: + return value + time.sleep(.025) + self.fail("Timed out: " + (root / "compositor.log").read_text()) + + def workspaces(): + return command("get-workspaces") + + initial = workspaces()[0]["id"] + self.assertEqual(len(workspaces()), 1) + command("workspace", "9") + self.assertEqual(workspaces()[0]["id"], initial) + self.assertEqual(len(workspaces()), 1, "numeric lookup must not create gaps") + env["WAYLAND_DISPLAY"] = str(next( + path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock") + )) + + first = launch(["foot", "--app-id", "ferese.test.workspace.first", "sleep", "120"], + stdout=log, stderr=log) + wait_for(lambda: len(workspaces()) == 2 and workspaces()[0]["window_count"] == 1) command("workspace", "2") - self.assertEqual(active(), "2") - self.assertEqual({w["name"] for w in command("get-workspaces")}, {"1", "2"}) + second_id = next(w["id"] for w in workspaces() if w["focused"]) + second = launch(["foot", "--app-id", "ferese.test.workspace.second", "sleep", "120"], + stdout=log, stderr=log) + wait_for(lambda: len(workspaces()) == 3 and workspaces()[1]["window_count"] == 1) for expected in ["1", "2", "1", "2"]: command("workspace-back-and-forth") self.assertEqual(active(), expected) - command("workspace", "3") - self.assertEqual({w["name"] for w in command("get-workspaces")}, {"2", "3"}, - "old empty history is pruned") + + os.killpg(first.pid, signal.SIGTERM) + first.wait(timeout=3) + wait_for(lambda: len(workspaces()) == 2) + self.assertEqual(workspaces()[0]["id"], second_id, "compaction preserves identity") + self.assertEqual([w["index"] for w in workspaces()], [1, 2]) + command("workspace-back-and-forth") + self.assertEqual(active(), "1", "removed history cannot resurrect an empty workspace") + spare_id = workspaces()[1]["id"] + command("workspace", "99") + self.assertEqual(active(), "2") + self.assertEqual(next(w["id"] for w in workspaces() if w["focused"]), spare_id) + command("workspace-back-and-forth") + self.assertEqual(active(), "1") + replacement = source.with_suffix(".new") replacement.write_text("animations { enabled #false; }\nworkspaces { auto-back-and-forth #true; }\n") replacement.replace(source) command("reload-config") - command("workspace", "3") - self.assertEqual(active(), "3", "literal IPC selection must not auto-toggle") + command("workspace", "1") + self.assertEqual(active(), "1", "explicit IPC selection must not auto-toggle") command("workspace-back-and-forth") - self.assertEqual(active(), "2", "reload must preserve history") + self.assertEqual(active(), "2") command("workspace-back-and-forth") - self.assertEqual(active(), "3") + self.assertEqual(active(), "1") + + os.killpg(second.pid, signal.SIGTERM) + second.wait(timeout=3) + wait_for(lambda: all(w["window_count"] == 0 for w in workspaces())) + command("workspace", "2") + wait_for(lambda: len(workspaces()) == 1) + self.assertEqual(workspaces()[0]["id"], spare_id) + self.assertEqual(workspaces()[0]["name"], "1") + self.assertTrue(workspaces()[0]["visible"]) + self.assertTrue(workspaces()[0]["focused"]) finally: for child in reversed(children): if child.poll() is None: diff --git a/shell/ferese-shell/src/bar.rs b/shell/ferese-shell/src/bar.rs index ac17361..9d49a15 100644 --- a/shell/ferese-shell/src/bar.rs +++ b/shell/ferese-shell/src/bar.rs @@ -38,20 +38,19 @@ impl FereseShell { .spacing(1) .align_y(cosmic::iced::Alignment::Center); - for workspace in &self.snapshot.workspaces { + for workspace in self + .snapshot + .workspaces_for_output(focused_output.map(|output| output.id)) + { let active = workspace_active_on_bar(workspace.id, focused_output); let owner = workspace .output .and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id)); - let occupied = self - .snapshot - .windows - .iter() - .any(|window| window.workspace == workspace.id); + let occupied = workspace.window_count > 0; let indicator = workspace_indicator( &workspace.name, active, - workspace.active && !active, + workspace.visible && !active, occupied, bar, shell_theme, @@ -59,8 +58,14 @@ impl FereseShell { let workspace_button = button::custom(indicator) .name(format!( "Workspace {}{}{}", - workspace.name, - if active { ", active" } else { "" }, + workspace.index, + if workspace.focused { + ", focused" + } else if active { + ", visible" + } else { + "" + }, owner.map_or(String::new(), |output| format!(", on {}", output.name)) )) .height(bar.control_height) diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index 2867e26..2be1155 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -19,6 +19,14 @@ pub(crate) struct ShellSnapshot { pub(crate) windows: Vec, } +impl ShellSnapshot { + pub(crate) fn workspaces_for_output(&self, output: Option) -> impl Iterator { + self.workspaces + .iter() + .filter(move |workspace| output.is_some() && workspace.output == output) + } +} + #[derive(Clone, Debug)] pub(crate) struct OutputSnapshot { pub(crate) id: u64, @@ -32,7 +40,10 @@ pub(crate) struct WorkspaceSnapshot { pub(crate) id: u64, pub(crate) name: String, pub(crate) output: Option, - pub(crate) active: bool, + pub(crate) index: u32, + pub(crate) window_count: u32, + pub(crate) visible: bool, + pub(crate) focused: bool, } #[derive(Clone, Debug)] @@ -249,7 +260,10 @@ impl Dispatch for ControlState { output_hi, output_lo, name, - active, + index, + window_count, + visible, + focused, } => { let output = join_id(output_hi, output_lo); @@ -257,7 +271,10 @@ impl Dispatch for ControlState { id: join_id(workspace_hi, workspace_lo), name, output: (output != 0).then_some(output), - active: active != 0, + index, + window_count, + visible: visible != 0, + focused: focused != 0, }); } ferese_shell_v1::Event::Window { @@ -286,9 +303,6 @@ impl Dispatch for ControlState { }); } ferese_shell_v1::Event::SnapshotEnd { serial } if state.serial.take() == Some(serial) => { - state.pending.workspaces.sort_unstable_by_key(|workspace| { - ferese_core::workspace_order_key(&workspace.name, ferese_core::WorkspaceId(workspace.id)) - }); let _ = state.sender.send(ControlUpdate::Snapshot(state.pending.clone())); } ferese_shell_v1::Event::LogoutRequested { serial, output_name } => { @@ -348,3 +362,41 @@ mod tests { assert!(config.is_none()); } } + +#[cfg(test)] +mod workspace_tests { + use super::*; + + #[test] + fn each_bar_uses_its_own_order_and_authoritative_occupancy() { + let workspace = |id, output, index, window_count| WorkspaceSnapshot { + id, + output: Some(output), + index, + name: index.to_string(), + window_count, + visible: index == 1, + focused: output == 1 && index == 1, + }; + let snapshot = ShellSnapshot { + workspaces: vec![workspace(7, 1, 1, 2), workspace(9, 1, 2, 0), workspace(3, 2, 1, 0)], + ..Default::default() + }; + + let left = snapshot.workspaces_for_output(Some(1)).collect::>(); + assert_eq!( + left.iter().map(|workspace| workspace.id).collect::>(), + vec![7, 9] + ); + assert_eq!(left[0].window_count, 2); + assert_eq!( + snapshot + .workspaces_for_output(Some(2)) + .map(|workspace| workspace.id) + .collect::>(), + vec![3] + ); + assert_eq!(snapshot.workspaces_for_output(None).count(), 0); + assert_eq!(snapshot.workspaces_for_output(Some(99)).count(), 0); + } +}