Unify per-monitor workspace numbering and lifecycle policy

This commit is contained in:
Samuel Onoja committed 2026-10-01 18:24:12 +01:00
1 parent 07bda5a21c
commit 1441875096
20 files changed
+707 -316

No files matched your search

+7 -28
View File
@@ -781,39 +781,18 @@ impl Ferese {
} }
} }
pub(crate) fn relative_workspace_target(&mut self, next: bool) -> Option<ferese_core::WorkspaceId> { pub(crate) fn relative_workspace_target(&self, next: bool) -> Option<ferese_core::WorkspaceId> {
let output = self.output_workspaces.focused_output()?; let output = self.output_workspaces.focused_output()?;
let current = self.output_workspaces.active_workspace(output)?; let current = self.output_workspaces.active_workspace(output)?;
let mut candidates = self.workspaces.ordered(); let candidates = self.output_workspaces.ordered_workspaces(&self.workspaces, output);
candidates.retain(|workspace| { let index = candidates.iter().position(|id| *id == current)?;
self.output_workspaces
.output_for_workspace(workspace.id)
.is_none_or(|owner| owner == output)
});
let index = candidates.iter().position(|workspace| workspace.id == current)?;
let neighbor = if next { let neighbor = if next {
index.checked_add(1) index.checked_add(1)?
} else { } else {
index.checked_sub(1) index.checked_sub(1)?
}; };
if let Some(workspace) = neighbor.and_then(|index| candidates.get(index)) {
return Some(workspace.id); candidates.get(neighbor).copied()
}
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::<u32>().ok())
.max()
.unwrap_or(1)
.checked_add(1)?;
return self.workspaces.ensure_numeric(number).ok();
}
None
} }
fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial, raise: bool) { fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial, raise: bool) {
+11 -6
View File
@@ -987,20 +987,25 @@ impl Ferese {
} }
fn workspaces_json(&self) -> Value { fn workspaces_json(&self) -> Value {
let active = self.workspaces.active_id();
let workspaces = self let workspaces = self
.workspaces .output_workspaces
.ordered() .workspace_views(&self.workspaces)
.into_iter() .into_iter()
.map(|workspace| { .map(|view| {
let workspace = self.workspaces.workspace(view.id).expect("workspace view exists");
let mode = match workspace.layout.mode() { let mode = match workspace.layout.mode() {
LayoutMode::Scrolling => "scrolling", LayoutMode::Scrolling => "scrolling",
LayoutMode::Tree => "tree", LayoutMode::Tree => "tree",
}; };
json!({ json!({
"id": workspace.id.0, "id": workspace.id.0,
"name": workspace.name, "name": view.index.to_string(),
"active": workspace.id == active, "index": view.index,
"output": view.output.0,
"window_count": view.window_count,
"visible": view.visible,
"focused": view.focused,
"active": view.focused,
"layout": mode, "layout": mode,
"focused_window": workspace.last_focused.map(|window| window.0), "focused_window": workspace.last_focused.map(|window| window.0),
"fullscreen_window": workspace.fullscreen.map(|window| window.0), "fullscreen_window": workspace.fullscreen.map(|window| window.0),
+15 -7
View File
@@ -125,6 +125,7 @@ impl Default for OverviewState {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct WorkspaceCard { pub(crate) struct WorkspaceCard {
pub workspace: WorkspaceId, pub workspace: WorkspaceId,
pub index: u32,
pub rect: Rect, pub rect: Rect,
pub selected: bool, pub selected: bool,
pub windows: Vec<(WindowId, Rect)>, pub windows: Vec<(WindowId, Rect)>,
@@ -459,12 +460,11 @@ impl Ferese {
pub(crate) fn overview_workspace_label( pub(crate) fn overview_workspace_label(
&mut self, &mut self,
workspace: WorkspaceId, index: u32,
scale: f64, scale: f64,
) -> Option<(MemoryRenderBuffer, smithay::utils::Size<i32, smithay::utils::Buffer>)> { ) -> Option<(MemoryRenderBuffer, smithay::utils::Size<i32, smithay::utils::Buffer>)> {
let name = self.workspaces.workspace(workspace)?.name.clone();
self.overview 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) { 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 selected = self.output_workspaces.active_workspace(output_id);
let strip = workspace_strip(bounds); let strip = workspace_strip(bounds);
let capacity = strip_capacity(strip); 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::<Vec<_>>();
let selected_index = workspaces let selected_index = workspaces
.iter() .iter()
.position(|workspace| Some(workspace.id) == selected) .position(|workspace| Some(workspace.id) == selected)
@@ -629,7 +634,9 @@ impl Ferese {
.skip(offset) .skip(offset)
.take(capacity) .take(capacity)
.enumerate() .enumerate()
.map(|(index, workspace)| { .map(|(index, view)| {
let workspace = self.workspaces.workspace(view.id).expect("workspace view exists");
let rect = Rect::new( let rect = Rect::new(
start_x + index as f64 * (card_width + WORKSPACE_CARD_GAP), start_x + index as f64 * (card_width + WORKSPACE_CARD_GAP),
strip.y + 12.0, strip.y + 12.0,
@@ -659,6 +666,7 @@ impl Ferese {
let windows = preview_layout(preview_bounds, &windows, 4.0, 4.0).into_iter().collect(); let windows = preview_layout(preview_bounds, &windows, 4.0, 4.0).into_iter().collect();
WorkspaceCard { WorkspaceCard {
workspace: workspace.id, workspace: workspace.id,
index: view.index,
rect, rect,
selected: Some(workspace.id) == selected, selected: Some(workspace.id) == selected,
windows, windows,
@@ -721,10 +729,10 @@ impl Ferese {
}; };
let capacity = strip_capacity(workspace_strip(bounds)); let capacity = strip_capacity(workspace_strip(bounds));
let cards = self.overview_workspace_cards(&output); 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 let current = cards
.first() .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); .unwrap_or(0);
let next = if delta > 0.0 { let next = if delta > 0.0 {
current.saturating_add(1) current.saturating_add(1)
+1 -1
View File
@@ -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::<i32, Physical>::from(( let location = Point::<i32, Physical>::from((
((card.rect.x - f64::from(output_geometry.loc.x) + card.rect.width * 0.5) * scale).round() as i32 ((card.rect.x - f64::from(output_geometry.loc.x) + card.rect.width * 0.5) * scale).round() as i32
- size.w / 2, - size.w / 2,
+26 -17
View File
@@ -318,20 +318,17 @@ impl Ferese {
} }
fn workspace_snapshots(&self) -> Vec<WorkspaceSnapshot> { fn workspace_snapshots(&self) -> Vec<WorkspaceSnapshot> {
self.workspaces self.output_workspaces
.ordered() .workspace_views(&self.workspaces)
.into_iter() .into_iter()
.map(|workspace| { .map(|view| WorkspaceSnapshot {
let output = self.output_workspaces.output_for_workspace(workspace.id); id: view.id,
let active = output: Some(view.output),
output.is_some_and(|output| self.output_workspaces.active_workspace(output) == Some(workspace.id)); name: view.index.to_string(),
index: view.index,
WorkspaceSnapshot { window_count: view.window_count as u32,
id: workspace.id, visible: view.visible,
output, focused: view.focused,
name: workspace.name.clone(),
active,
}
}) })
.collect() .collect()
} }
@@ -456,7 +453,10 @@ impl ShellSnapshot {
output_hi, output_hi,
output_lo, output_lo,
workspace.name.clone(), 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 { for snapshot in &self.windows {
@@ -499,7 +499,10 @@ struct WorkspaceSnapshot {
id: WorkspaceId, id: WorkspaceId,
output: Option<OutputId>, output: Option<OutputId>,
name: String, name: String,
active: bool, index: u32,
window_count: u32,
visible: bool,
focused: bool,
} }
fn split_id(id: u64) -> (u32, u32) { fn split_id(id: u64) -> (u32, u32) {
@@ -559,7 +562,10 @@ mod tests {
id: WorkspaceId(1), id: WorkspaceId(1),
output: Some(OutputId(1)), output: Some(OutputId(1)),
name: "1".into(), name: "1".into(),
active: true, index: 1,
window_count: 1,
visible: true,
focused: true,
}], }],
windows: vec![ManagedWindowSnapshot { windows: vec![ManagedWindowSnapshot {
id: WindowId(1), id: WindowId(1),
@@ -575,7 +581,10 @@ mod tests {
|s| s.outputs[0].active_workspace = WorkspaceId(2), |s| s.outputs[0].active_workspace = WorkspaceId(2),
|s| s.outputs.clear(), |s| s.outputs.clear(),
|s| s.workspaces[0].output = None, |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.workspaces[0].name = "renamed".into(),
|s| s.windows[0].workspace = 2, |s| s.windows[0].workspace = 2,
|s| s.windows[0].title = "new title".into(), |s| s.windows[0].title = "new title".into(),
+2 -2
View File
@@ -1016,7 +1016,7 @@ mod tests {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
let first = workspaces.active_id(); let first = workspaces.active_id();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); 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.activate(second).unwrap();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces.insert_window(WindowId(3), 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) .connect(OutputId(1), OutputGeometry::new(0, 0, 1920, 1080), first)
.unwrap(); .unwrap();
let owner = if assignment == Some(OutputId(2)) { let owner = if assignment == Some(OutputId(2)) {
let third = workspaces.ensure_numeric(3).unwrap(); let third = workspaces.create_workspace();
outputs outputs
.connect(OutputId(2), OutputGeometry::new(1920, 0, 1920, 1080), third) .connect(OutputId(2), OutputGeometry::new(1920, 0, 1920, 1080), third)
.unwrap(); .unwrap();
+1 -1
View File
@@ -313,7 +313,7 @@ impl Ferese {
}) })
.collect::<HashSet<_>>(); .collect::<HashSet<_>>();
self.unmap_invisible_windows(&visible_windows); self.unmap_invisible_windows(&visible_windows);
self.prune_empty_workspaces(&visible); self.reconcile_workspaces(&visible);
self.send_shell_snapshots(); self.send_shell_snapshots();
active_animation = true; active_animation = true;
} }
+6 -6
View File
@@ -15,11 +15,11 @@ impl Ferese {
visible visible
} }
pub(super) fn prune_empty_workspaces(&mut self, visible: &HashSet<WorkspaceId>) { pub(super) fn reconcile_workspaces(&mut self, visible: &HashSet<WorkspaceId>) {
let mut protected = visible.clone(); for workspace in self
protected.extend(self.output_workspaces.history_workspaces()); .output_workspaces
for workspace in self.workspaces.prune_empty(&protected) { .reconcile_workspaces(&mut self.workspaces, visible)
self.output_workspaces.forget_workspace(workspace); {
self.viewport_animations.remove(&workspace); self.viewport_animations.remove(&workspace);
} }
} }
@@ -52,7 +52,7 @@ impl Ferese {
pub fn relayout(&mut self) { pub fn relayout(&mut self) {
self.refresh_input_capture_zones(); self.refresh_input_capture_zones();
let visible_workspaces = self.visible_workspace_ids(); let visible_workspaces = self.visible_workspace_ids();
self.prune_empty_workspaces(&visible_workspaces); self.reconcile_workspaces(&visible_workspaces);
if self.session_lock.active { if self.session_lock.active {
self.configure_lock_surfaces(); self.configure_lock_surfaces();
} }
+14 -20
View File
@@ -394,16 +394,13 @@ impl Ferese {
slide_direction: Option<SwipeDirection>, slide_direction: Option<SwipeDirection>,
auto_back_and_forth: bool, 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 { let Some(output) = self.output_workspaces.focused_output() else {
return; return;
}; };
let Some(workspace) = self.output_workspaces.workspace_at(&self.workspaces, output, index) else {
return;
};
let previous = self.output_workspaces.active_workspace(output); let previous = self.output_workspaces.active_workspace(output);
let owner = match self let owner = match self
.output_workspaces .output_workspaces
@@ -526,26 +523,23 @@ impl Ferese {
let Some(window) = self.focused_window else { let Some(window) = self.focused_window else {
return; 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 let axis = self
.output_bounds() .output_bounds()
.and_then(|bounds| { .and_then(|bounds| {
let workspace = self.workspaces.ensure_numeric(index).ok()?; let target = self.workspaces.workspace(destination)?;
let target = self.workspaces.workspace(workspace)?;
target.layout.automatic_axis(target.last_focused, bounds).ok() target.layout.automatic_axis(target.last_focused, bounds).ok()
}) })
.unwrap_or(Axis::Horizontal); .unwrap_or(Axis::Horizontal);
let destination = match self.workspaces.move_window_to_numeric(window, index, axis, 0.5) { if let Err(error) = self.workspaces.move_window_to_workspace(window, destination, axis, 0.5) {
Ok(destination) => destination, tracing::error!(%error, ?window, index, "failed to move window to workspace");
Err(error) => { return;
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");
} }
self.focused_window = self.workspaces.active().last_focused; self.focused_window = self.workspaces.active().last_focused;
+11 -13
View File
@@ -15,25 +15,23 @@ impl Ferese {
self.next_output_id = self.next_output_id.saturating_add(1); self.next_output_id = self.next_output_id.saturating_add(1);
id id
}); });
let fallback_workspace = (1..) let fallback_workspace = self
.find_map(|index| { .workspaces
let workspace = self.workspaces.ensure_numeric(index).ok()?; .iter()
self.output_workspaces .filter(|workspace| self.output_workspaces.output_for_workspace(workspace.id).is_none())
.output_for_workspace(workspace) .map(|workspace| workspace.id)
.is_none() .min_by_key(|id| id.0)
.then_some(workspace) .unwrap_or_else(|| self.workspaces.create_workspace());
})
.expect("numeric workspaces are inexhaustible");
let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h); 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); let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace);
match registration { match registration {
Ok(workspace) => { Ok(_) => {
self.output_ids.insert(output.clone(), output_id); self.output_ids.insert(output.clone(), output_id);
if self.output_workspaces.focused_output() == Some(output_id) { self.restore_output_focus();
self.activate_output_workspace(output_id, workspace); let visible = self.visible_workspace_ids();
} self.reconcile_workspaces(&visible);
self.send_shell_snapshots(); self.send_shell_snapshots();
} }
Err(error) => { Err(error) => {
+8 -3
View File
@@ -287,10 +287,15 @@ impl Ferese {
.automatic_axis(self.focused_window, bounds) .automatic_axis(self.focused_window, bounds)
.unwrap_or(Axis::Horizontal); .unwrap_or(Axis::Horizontal);
if let Some(workspace) = rule.workspace if let Some(index) = rule.workspace
&& let Err(error) = self.workspaces.move_window_to_numeric(id, workspace, axis, 0.5) && 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); let bounds = self.floating_bounds_for_window(id).unwrap_or(bounds);
+2 -1
View File
@@ -3,10 +3,11 @@ mod focus;
pub mod notifications; pub mod notifications;
mod output; mod output;
mod workspace; mod workspace;
mod workspace_policy;
pub use focus::{FocusCycle, FocusHistory}; pub use focus::{FocusCycle, FocusHistory};
pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch}; pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch};
pub use workspace::{ pub use workspace::{
LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet, LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet,
workspace_order_key,
}; };
pub use workspace_policy::WorkspaceView;
+29 -31
View File
@@ -107,10 +107,6 @@ impl OutputWorkspaceMap {
self.outputs.get(&output).and_then(|state| state.previous) self.outputs.get(&output).and_then(|state| state.previous)
} }
pub fn history_workspaces(&self) -> impl Iterator<Item = WorkspaceId> + '_ {
self.outputs.values().filter_map(|state| state.previous)
}
pub fn output_for_workspace(&self, workspace: WorkspaceId) -> Option<OutputId> { pub fn output_for_workspace(&self, workspace: WorkspaceId) -> Option<OutputId> {
self.assignments.get(&workspace).copied() self.assignments.get(&workspace).copied()
} }
@@ -171,6 +167,19 @@ impl OutputWorkspaceMap {
let revision_matches = self.revision(evacuated.workspace) == evacuated.revision; let revision_matches = self.revision(evacuated.workspace) == evacuated.revision;
if assignment_matches && revision_matches { 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 if let Some(target) = evacuated.target
&& let Some(target_state) = self.outputs.get_mut(&target) && let Some(target_state) = self.outputs.get_mut(&target)
{ {
@@ -413,6 +422,22 @@ impl OutputWorkspaceMap {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn forgetting_an_inactive_workspace_prevents_hotplug_resurrection() { fn forgetting_an_inactive_workspace_prevents_hotplug_resurrection() {
let mut outputs = super::OutputWorkspaceMap::default(); 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] #[test]
fn new_outputs_receive_distinct_workspaces() { fn new_outputs_receive_distinct_workspaces() {
let mut outputs = OutputWorkspaceMap::default(); let mut outputs = OutputWorkspaceMap::default();
+74 -139
View File
@@ -10,14 +10,9 @@ use ferese_layout::{
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct WorkspaceId(pub u64); pub struct WorkspaceId(pub u64);
pub fn workspace_order_key(name: &str, id: WorkspaceId) -> (u64, u64) {
(name.parse::<u64>().unwrap_or(u64::MAX), id.0)
}
#[derive(Debug)] #[derive(Debug)]
pub struct Workspace { pub struct Workspace {
pub id: WorkspaceId, pub id: WorkspaceId,
pub name: String,
pub layout: WorkspaceLayout, pub layout: WorkspaceLayout,
pub floating: Vec<WindowId>, pub floating: Vec<WindowId>,
pub last_focused: Option<WindowId>, pub last_focused: Option<WindowId>,
@@ -339,7 +334,6 @@ impl From<LayoutError> for WorkspaceError {
pub struct WorkspaceSet { pub struct WorkspaceSet {
active: WorkspaceId, active: WorkspaceId,
workspaces: HashMap<WorkspaceId, Workspace>, workspaces: HashMap<WorkspaceId, Workspace>,
names: HashMap<String, WorkspaceId>,
window_workspaces: HashMap<WindowId, WorkspaceId>, window_workspaces: HashMap<WindowId, WorkspaceId>,
placements: HashMap<WindowId, WindowPlacement>, placements: HashMap<WindowId, WindowPlacement>,
default_layout_mode: LayoutMode, default_layout_mode: LayoutMode,
@@ -436,19 +430,16 @@ impl WorkspaceSet {
let active = WorkspaceId(1); let active = WorkspaceId(1);
let workspace = Workspace { let workspace = Workspace {
id: active, id: active,
name: "1".to_owned(),
layout: WorkspaceLayout::new(default_layout_mode, default_column_width, scrolling_focus_strategy), layout: WorkspaceLayout::new(default_layout_mode, default_column_width, scrolling_focus_strategy),
floating: Vec::new(), floating: Vec::new(),
last_focused: None, last_focused: None,
fullscreen: None, fullscreen: None,
}; };
let workspaces = HashMap::from([(active, workspace)]); let workspaces = HashMap::from([(active, workspace)]);
let names = HashMap::from([("1".to_owned(), active)]);
Self { Self {
active, active,
workspaces, workspaces,
names,
window_workspaces: HashMap::new(), window_workspaces: HashMap::new(),
placements: HashMap::new(), placements: HashMap::new(),
default_layout_mode, default_layout_mode,
@@ -500,36 +491,6 @@ impl WorkspaceSet {
self.workspaces.values() self.workspaces.values()
} }
pub fn ordered(&self) -> Vec<&Workspace> {
let mut workspaces = self.iter().collect::<Vec<_>>();
workspaces.sort_unstable_by_key(|workspace| workspace_order_key(&workspace.name, workspace.id));
workspaces
}
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()
} }
@@ -680,25 +641,39 @@ impl WorkspaceSet {
Ok(changed) Ok(changed)
} }
pub fn ensure_numeric(&mut self, index: u32) -> Result<WorkspaceId, WorkspaceError> { 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<WorkspaceId, WorkspaceError> {
if index == 0 { if index == 0 {
return Err(WorkspaceError::InvalidNumericName(index)); return Err(WorkspaceError::InvalidNumericName(index));
} }
let name = index.to_string(); while self.next_id <= u64::from(index) {
self.create_workspace();
if let Some(id) = self.names.get(&name) {
return Ok(*id);
} }
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); let id = WorkspaceId(self.next_id);
self.next_id += 1; self.next_id = self.next_id.checked_add(1).expect("workspace IDs exhausted");
self.names.insert(name.clone(), id);
self.workspaces.insert( self.workspaces.insert(
id, id,
Workspace { Workspace {
id, id,
name,
layout: WorkspaceLayout::new( layout: WorkspaceLayout::new(
self.default_layout_mode, self.default_layout_mode,
self.default_column_width, self.default_column_width,
@@ -711,11 +686,12 @@ impl WorkspaceSet {
); );
debug_assert!(self.validate().is_ok()); debug_assert!(self.validate().is_ok());
Ok(id) id
} }
pub fn switch_to_numeric(&mut self, index: u32) -> Result<Option<WindowId>, WorkspaceError> { #[cfg(test)]
let workspace = self.ensure_numeric(index)?; fn switch_to_fixture(&mut self, index: u32) -> Result<Option<WindowId>, WorkspaceError> {
let workspace = self.fixture_workspace(index)?;
self.activate(workspace) self.activate(workspace)
} }
@@ -798,17 +774,32 @@ impl WorkspaceSet {
Ok(()) Ok(())
} }
pub fn move_window_to_numeric( #[cfg(test)]
fn move_window_to_fixture(
&mut self, &mut self,
window: WindowId, window: WindowId,
index: u32, index: u32,
axis: Axis, axis: Axis,
ratio: f64, ratio: f64,
) -> Result<WorkspaceId, WorkspaceError> { ) -> Result<WorkspaceId, WorkspaceError> {
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<WorkspaceId, WorkspaceError> {
if self.workspace(destination_id).is_none() {
return Err(WorkspaceError::UnknownWorkspace(destination_id));
}
let source_id = self let source_id = self
.workspace_for_window(window) .workspace_for_window(window)
.ok_or(LayoutError::UnknownWindow(window))?; .ok_or(LayoutError::UnknownWindow(window))?;
let destination_id = self.ensure_numeric(index)?;
if source_id == destination_id { if source_id == destination_id {
return Ok(destination_id); return Ok(destination_id);
@@ -943,14 +934,11 @@ impl WorkspaceSet {
if !self.workspaces.contains_key(&self.active) { if !self.workspaces.contains_key(&self.active) {
return Err(WorkspaceError::InvalidState("active workspace is missing")); 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(); let mut seen_windows = HashSet::new();
for (id, workspace) in &self.workspaces { 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")); return Err(WorkspaceError::InvalidState("workspace index is inconsistent"));
} }
@@ -1033,70 +1021,6 @@ fn finite_or_zero(value: f64) -> f64 {
#[cfg(test)] #[cfg(test)]
mod tests { 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<_>>(),
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] #[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();
@@ -1149,21 +1073,22 @@ mod tests {
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5)); assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5));
assert_eq!(layout.default_width(), ColumnWidth::Full); assert_eq!(layout.default_width(), ColumnWidth::Full);
assert_eq!(workspaces.workspace_for_window(WindowId(1)), Some(workspace)); 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.active().layout.mode(), LayoutMode::Tree);
assert_eq!(workspaces.workspace_for_window(WindowId(1)), Some(workspace)); assert_eq!(workspaces.workspace_for_window(WindowId(1)), Some(workspace));
} }
use super::*; use super::*;
#[test] #[test]
fn numeric_workspaces_are_created_lazily_with_stable_ids() { fn workspace_creation_uses_fresh_ids() {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
let first = workspaces.active_id(); 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)); let third = workspaces.create_workspace();
assert_eq!(workspaces.ensure_numeric(9), Ok(ninth)); assert_ne!(first, third);
assert_ne!(first, ninth); assert_ne!(second, third);
assert!(workspaces.validate().is_ok()); assert!(workspaces.validate().is_ok());
} }
@@ -1184,7 +1109,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(first.geometry[&WindowId(1)], Rect::new(4.0, 4.0, 992.0, 792.0)); 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); assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling);
} }
@@ -1193,11 +1118,11 @@ mod tests {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); 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(); 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_fixture(1).unwrap(), Some(WindowId(1)));
assert_eq!(workspaces.switch_to_numeric(2).unwrap(), Some(WindowId(2))); assert_eq!(workspaces.switch_to_fixture(2).unwrap(), Some(WindowId(2)));
assert!(workspaces.validate().is_ok()); assert!(workspaces.validate().is_ok());
} }
@@ -1208,7 +1133,7 @@ mod tests {
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
let first = workspaces.active_id(); let first = workspaces.active_id();
let second = workspaces 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(); .unwrap();
assert_eq!(workspaces.active_id(), first); assert_eq!(workspaces.active_id(), first);
@@ -1247,7 +1172,7 @@ mod tests {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces workspaces
.move_window_to_numeric(WindowId(1), 2, Axis::Horizontal, 0.5) .move_window_to_fixture(WindowId(1), 2, Axis::Horizontal, 0.5)
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -1402,7 +1327,7 @@ mod tests {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
let parent_workspace = workspaces.active_id(); let parent_workspace = workspaces.active_id();
workspaces.switch_to_numeric(2).unwrap(); workspaces.switch_to_fixture(2).unwrap();
workspaces workspaces
.insert_floating_window( .insert_floating_window(
WindowId(2), WindowId(2),
@@ -1463,7 +1388,7 @@ mod tests {
assert_eq!(workspaces.active().last_focused, Some(WindowId(2))); assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
assert!(workspaces.focus_window(WindowId(2)).is_ok()); 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.activate(workspace).unwrap(), Some(WindowId(2)));
assert_eq!(workspaces.active().fullscreen, Some(WindowId(1))); assert_eq!(workspaces.active().fullscreen, Some(WindowId(1)));
assert!(workspaces.validate().is_ok()); assert!(workspaces.validate().is_ok());
@@ -1474,13 +1399,13 @@ mod tests {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap(); workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces.toggle_fullscreen(WindowId(1)).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.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces workspaces
.move_window_to_numeric(WindowId(2), 1, Axis::Horizontal, 0.5) .move_window_to_fixture(WindowId(2), 1, Axis::Horizontal, 0.5)
.unwrap(); .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.active().last_focused, Some(WindowId(1)));
assert_eq!( assert_eq!(
workspaces.focus_window(WindowId(2)), workspaces.focus_window(WindowId(2)),
@@ -1586,13 +1511,13 @@ mod tests {
} }
2 => { 2 => {
workspaces workspaces
.switch_to_numeric((random.rotate_left(11) % 4 + 1) as u32) .switch_to_fixture((random.rotate_left(11) % 4 + 1) as u32)
.unwrap(); .unwrap();
} }
3 if !windows.is_empty() => { 3 if !windows.is_empty() => {
let window = windows[random as usize % windows.len()]; let window = windows[random as usize % windows.len()];
workspaces workspaces
.move_window_to_numeric( .move_window_to_fixture(
window, window,
(random.rotate_left(17) % 4 + 1) as u32, (random.rotate_left(17) % 4 + 1) as u32,
random_axis(random), 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()
}
}
+352
View File
@@ -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<WorkspaceView> {
let mut outputs = self.connected_outputs().collect::<Vec<_>>();
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<WorkspaceId> {
let mut ids = workspaces
.iter()
.filter(|workspace| self.output_for_workspace(workspace.id) == Some(output))
.map(|workspace| workspace.id)
.collect::<Vec<_>>();
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<WorkspaceId> {
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<WorkspaceId>,
) -> Vec<WorkspaceId> {
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::<Vec<_>>();
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::<Vec<_>>();
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<_>>(),
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::<Vec<_>>();
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::<Vec<_>>();
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));
}
}
}
}
}
+13 -11
View File
@@ -221,18 +221,20 @@ theme {
## Workspaces ## Workspaces
A single-output session starts with workspace 1. Numbered workspace shortcuts create Each monitor has an independent list of workspaces, numbered from 1. Its bar,
workspaces on demand; switching forward past the last workspace also creates the overview strip, and numeric shortcuts use that same order. Numbers change when
next one when the current workspace contains windows. Empty workspaces are empty workspaces are removed; internal workspace IDs stay stable.
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.
`Super+Escape` returns to the previous workspace on the focused monitor. Repeat One empty workspace is always available at the end of each monitor's list.
it to switch between the two most recently visited workspaces. Switching to a Opening or moving a window there creates another spare. An empty workspace stays
workspace on another monitor updates that monitor's history. History is cleared while displayed or participating in a swipe, then disappears after you leave it.
for a disconnected monitor; it does not jump to a workspace reclaimed by another Selecting a number beyond the current list selects the trailing empty workspace.
monitor on reconnect. With no previous workspace, the action does nothing. 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: Enable **Settings → Windows → Toggle back with the same workspace shortcut**, or:
+4 -1
View File
@@ -110,7 +110,10 @@
<arg name="output_hi" type="uint" summary="zero when the workspace has no output"/> <arg name="output_hi" type="uint" summary="zero when the workspace has no output"/>
<arg name="output_lo" type="uint" summary="zero when the workspace has no output"/> <arg name="output_lo" type="uint" summary="zero when the workspace has no output"/>
<arg name="name" type="string"/> <arg name="name" type="string"/>
<arg name="active" type="uint" summary="one when visible on its output"/> <arg name="index" type="uint" summary="one-based position on this output"/>
<arg name="window_count" type="uint" summary="managed windows, including floating windows"/>
<arg name="visible" type="uint" summary="one when displayed on its output"/>
<arg name="focused" type="uint" summary="one when visible on the focused output"/>
</event> </event>
<event name="snapshot_end"> <event name="snapshot_end">
@@ -1,7 +1,7 @@
"""Workspace history against a temporary nested compositor. """Workspace history against a temporary nested compositor.
FERESE_TEST_WORKSPACE_HISTORY=1 python3 scripts/tests/test_workspace_history_isolated.py 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 json
import os import os
@@ -15,7 +15,7 @@ import unittest
@unittest.skipUnless(os.environ.get("FERESE_TEST_WORKSPACE_HISTORY") == "1", "nested workspace test is opt-in") @unittest.skipUnless(os.environ.get("FERESE_TEST_WORKSPACE_HISTORY") == "1", "nested workspace test is opt-in")
class WorkspaceHistoryTest(unittest.TestCase): 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] repo = Path(__file__).resolve().parents[2]
with tempfile.TemporaryDirectory(prefix="ferese-workspace-test-") as temporary: with tempfile.TemporaryDirectory(prefix="ferese-workspace-test-") as temporary:
root = Path(temporary) root = Path(temporary)
@@ -59,28 +59,73 @@ class WorkspaceHistoryTest(unittest.TestCase):
if compositor.poll() is not None or time.monotonic() >= deadline: if compositor.poll() is not None or time.monotonic() >= deadline:
self.fail((root / "compositor.log").read_text()) self.fail((root / "compositor.log").read_text())
time.sleep(.025) time.sleep(.025)
self.assertEqual(active(), "1") def wait_for(predicate):
command("workspace-back-and-forth") deadline = time.monotonic() + 10
self.assertEqual(active(), "1", "no history is a no-op") 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") command("workspace", "2")
self.assertEqual(active(), "2") second_id = next(w["id"] for w in workspaces() if w["focused"])
self.assertEqual({w["name"] for w in command("get-workspaces")}, {"1", "2"}) 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"]: for expected in ["1", "2", "1", "2"]:
command("workspace-back-and-forth") command("workspace-back-and-forth")
self.assertEqual(active(), expected) self.assertEqual(active(), expected)
command("workspace", "3")
self.assertEqual({w["name"] for w in command("get-workspaces")}, {"2", "3"}, os.killpg(first.pid, signal.SIGTERM)
"old empty history is pruned") 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 = source.with_suffix(".new")
replacement.write_text("animations { enabled #false; }\nworkspaces { auto-back-and-forth #true; }\n") replacement.write_text("animations { enabled #false; }\nworkspaces { auto-back-and-forth #true; }\n")
replacement.replace(source) replacement.replace(source)
command("reload-config") command("reload-config")
command("workspace", "3") command("workspace", "1")
self.assertEqual(active(), "3", "literal IPC selection must not auto-toggle") self.assertEqual(active(), "1", "explicit IPC selection must not auto-toggle")
command("workspace-back-and-forth") command("workspace-back-and-forth")
self.assertEqual(active(), "2", "reload must preserve history") self.assertEqual(active(), "2")
command("workspace-back-and-forth") 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: finally:
for child in reversed(children): for child in reversed(children):
if child.poll() is None: if child.poll() is None:
+14 -9
View File
@@ -38,20 +38,19 @@ impl FereseShell {
.spacing(1) .spacing(1)
.align_y(cosmic::iced::Alignment::Center); .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 active = workspace_active_on_bar(workspace.id, focused_output);
let owner = workspace let owner = workspace
.output .output
.and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id)); .and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id));
let occupied = self let occupied = workspace.window_count > 0;
.snapshot
.windows
.iter()
.any(|window| window.workspace == workspace.id);
let indicator = workspace_indicator( let indicator = workspace_indicator(
&workspace.name, &workspace.name,
active, active,
workspace.active && !active, workspace.visible && !active,
occupied, occupied,
bar, bar,
shell_theme, shell_theme,
@@ -59,8 +58,14 @@ impl FereseShell {
let workspace_button = button::custom(indicator) let workspace_button = button::custom(indicator)
.name(format!( .name(format!(
"Workspace {}{}{}", "Workspace {}{}{}",
workspace.name, workspace.index,
if active { ", active" } else { "" }, if workspace.focused {
", focused"
} else if active {
", visible"
} else {
""
},
owner.map_or(String::new(), |output| format!(", on {}", output.name)) owner.map_or(String::new(), |output| format!(", on {}", output.name))
)) ))
.height(bar.control_height) .height(bar.control_height)
+58 -6
View File
@@ -19,6 +19,14 @@ pub(crate) struct ShellSnapshot {
pub(crate) windows: Vec<WindowSnapshot>, pub(crate) windows: Vec<WindowSnapshot>,
} }
impl ShellSnapshot {
pub(crate) fn workspaces_for_output(&self, output: Option<u64>) -> impl Iterator<Item = &WorkspaceSnapshot> {
self.workspaces
.iter()
.filter(move |workspace| output.is_some() && workspace.output == output)
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct OutputSnapshot { pub(crate) struct OutputSnapshot {
pub(crate) id: u64, pub(crate) id: u64,
@@ -32,7 +40,10 @@ pub(crate) struct WorkspaceSnapshot {
pub(crate) id: u64, pub(crate) id: u64,
pub(crate) name: String, pub(crate) name: String,
pub(crate) output: Option<u64>, pub(crate) output: Option<u64>,
pub(crate) active: bool, pub(crate) index: u32,
pub(crate) window_count: u32,
pub(crate) visible: bool,
pub(crate) focused: bool,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -249,7 +260,10 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
output_hi, output_hi,
output_lo, output_lo,
name, name,
active, index,
window_count,
visible,
focused,
} => { } => {
let output = join_id(output_hi, output_lo); let output = join_id(output_hi, output_lo);
@@ -257,7 +271,10 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
id: join_id(workspace_hi, workspace_lo), id: join_id(workspace_hi, workspace_lo),
name, name,
output: (output != 0).then_some(output), output: (output != 0).then_some(output),
active: active != 0, index,
window_count,
visible: visible != 0,
focused: focused != 0,
}); });
} }
ferese_shell_v1::Event::Window { ferese_shell_v1::Event::Window {
@@ -286,9 +303,6 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
}); });
} }
ferese_shell_v1::Event::SnapshotEnd { serial } if state.serial.take() == Some(serial) => { 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())); let _ = state.sender.send(ControlUpdate::Snapshot(state.pending.clone()));
} }
ferese_shell_v1::Event::LogoutRequested { serial, output_name } => { ferese_shell_v1::Event::LogoutRequested { serial, output_name } => {
@@ -348,3 +362,41 @@ mod tests {
assert!(config.is_none()); 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::<Vec<_>>();
assert_eq!(
left.iter().map(|workspace| workspace.id).collect::<Vec<_>>(),
vec![7, 9]
);
assert_eq!(left[0].window_count, 2);
assert_eq!(
snapshot
.workspaces_for_output(Some(2))
.map(|workspace| workspace.id)
.collect::<Vec<_>>(),
vec![3]
);
assert_eq!(snapshot.workspaces_for_output(None).count(), 0);
assert_eq!(snapshot.workspaces_for_output(Some(99)).count(), 0);
}
}