Unify per-monitor workspace numbering and lifecycle policy
This commit is contained in:
1 parent
07bda5a21c
commit
1441875096
20 files changed
+707
-316
No files matched your search
@@ -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;
|
||||
@@ -107,10 +107,6 @@ impl OutputWorkspaceMap {
|
||||
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> {
|
||||
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();
|
||||
|
||||
@@ -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::<u64>().unwrap_or(u64::MAX), id.0)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Workspace {
|
||||
pub id: WorkspaceId,
|
||||
pub name: String,
|
||||
pub layout: WorkspaceLayout,
|
||||
pub floating: Vec<WindowId>,
|
||||
pub last_focused: Option<WindowId>,
|
||||
@@ -339,7 +334,6 @@ impl From<LayoutError> for WorkspaceError {
|
||||
pub struct WorkspaceSet {
|
||||
active: WorkspaceId,
|
||||
workspaces: HashMap<WorkspaceId, Workspace>,
|
||||
names: HashMap<String, WorkspaceId>,
|
||||
window_workspaces: HashMap<WindowId, WorkspaceId>,
|
||||
placements: HashMap<WindowId, WindowPlacement>,
|
||||
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::<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> {
|
||||
self.window_workspaces.get(&window).copied()
|
||||
}
|
||||
@@ -680,25 +641,39 @@ impl WorkspaceSet {
|
||||
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 {
|
||||
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<Option<WindowId>, WorkspaceError> {
|
||||
let workspace = self.ensure_numeric(index)?;
|
||||
#[cfg(test)]
|
||||
fn switch_to_fixture(&mut self, index: u32) -> Result<Option<WindowId>, 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<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
|
||||
.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<_>>(),
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user