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
+705 -314

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 current = self.output_workspaces.active_workspace(output)?;
let mut candidates = self.workspaces.ordered();
candidates.retain(|workspace| {
self.output_workspaces
.output_for_workspace(workspace.id)
.is_none_or(|owner| owner == output)
});
let index = candidates.iter().position(|workspace| workspace.id == current)?;
let candidates = self.output_workspaces.ordered_workspaces(&self.workspaces, output);
let index = candidates.iter().position(|id| *id == current)?;
let neighbor = if next {
index.checked_add(1)
index.checked_add(1)?
} else {
index.checked_sub(1)
index.checked_sub(1)?
};
if let Some(workspace) = neighbor.and_then(|index| candidates.get(index)) {
return Some(workspace.id);
}
if next
&& self.workspaces.workspace(current).is_some_and(|workspace| {
workspace.layout.window_ids().next().is_some() || !workspace.floating.is_empty()
})
{
let number = self
.workspaces
.iter()
.filter_map(|workspace| workspace.name.parse::<u32>().ok())
.max()
.unwrap_or(1)
.checked_add(1)?;
return self.workspaces.ensure_numeric(number).ok();
}
None
candidates.get(neighbor).copied()
}
fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial, raise: bool) {
+11 -6
View File
@@ -987,20 +987,25 @@ impl Ferese {
}
fn workspaces_json(&self) -> Value {
let active = self.workspaces.active_id();
let workspaces = self
.workspaces
.ordered()
.output_workspaces
.workspace_views(&self.workspaces)
.into_iter()
.map(|workspace| {
.map(|view| {
let workspace = self.workspaces.workspace(view.id).expect("workspace view exists");
let mode = match workspace.layout.mode() {
LayoutMode::Scrolling => "scrolling",
LayoutMode::Tree => "tree",
};
json!({
"id": workspace.id.0,
"name": workspace.name,
"active": workspace.id == active,
"name": view.index.to_string(),
"index": view.index,
"output": view.output.0,
"window_count": view.window_count,
"visible": view.visible,
"focused": view.focused,
"active": view.focused,
"layout": mode,
"focused_window": workspace.last_focused.map(|window| window.0),
"fullscreen_window": workspace.fullscreen.map(|window| window.0),
+15 -7
View File
@@ -125,6 +125,7 @@ impl Default for OverviewState {
#[derive(Clone, Debug)]
pub(crate) struct WorkspaceCard {
pub workspace: WorkspaceId,
pub index: u32,
pub rect: Rect,
pub selected: bool,
pub windows: Vec<(WindowId, Rect)>,
@@ -459,12 +460,11 @@ impl Ferese {
pub(crate) fn overview_workspace_label(
&mut self,
workspace: WorkspaceId,
index: u32,
scale: f64,
) -> Option<(MemoryRenderBuffer, smithay::utils::Size<i32, smithay::utils::Buffer>)> {
let name = self.workspaces.workspace(workspace)?.name.clone();
self.overview
.label(&name, scale, self.theme_settings.text_primary_color.0)
.label(&index.to_string(), scale, self.theme_settings.text_primary_color.0)
}
pub(crate) fn set_overview_active(&mut self, active: bool) {
@@ -606,7 +606,12 @@ impl Ferese {
let selected = self.output_workspaces.active_workspace(output_id);
let strip = workspace_strip(bounds);
let capacity = strip_capacity(strip);
let workspaces = self.workspaces.ordered();
let workspaces = self
.output_workspaces
.workspace_views(&self.workspaces)
.into_iter()
.filter(|view| view.output == output_id)
.collect::<Vec<_>>();
let selected_index = workspaces
.iter()
.position(|workspace| Some(workspace.id) == selected)
@@ -629,7 +634,9 @@ impl Ferese {
.skip(offset)
.take(capacity)
.enumerate()
.map(|(index, workspace)| {
.map(|(index, view)| {
let workspace = self.workspaces.workspace(view.id).expect("workspace view exists");
let rect = Rect::new(
start_x + index as f64 * (card_width + WORKSPACE_CARD_GAP),
strip.y + 12.0,
@@ -659,6 +666,7 @@ impl Ferese {
let windows = preview_layout(preview_bounds, &windows, 4.0, 4.0).into_iter().collect();
WorkspaceCard {
workspace: workspace.id,
index: view.index,
rect,
selected: Some(workspace.id) == selected,
windows,
@@ -721,10 +729,10 @@ impl Ferese {
};
let capacity = strip_capacity(workspace_strip(bounds));
let cards = self.overview_workspace_cards(&output);
let ordered = self.workspaces.ordered();
let ordered = self.output_workspaces.ordered_workspaces(&self.workspaces, output_id);
let current = cards
.first()
.and_then(|card| ordered.iter().position(|workspace| workspace.id == card.workspace))
.and_then(|card| ordered.iter().position(|id| *id == card.workspace))
.unwrap_or(0);
let next = if delta > 0.0 {
current.saturating_add(1)
+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((
((card.rect.x - f64::from(output_geometry.loc.x) + card.rect.width * 0.5) * scale).round() as i32
- size.w / 2,
+26 -17
View File
@@ -318,20 +318,17 @@ impl Ferese {
}
fn workspace_snapshots(&self) -> Vec<WorkspaceSnapshot> {
self.workspaces
.ordered()
self.output_workspaces
.workspace_views(&self.workspaces)
.into_iter()
.map(|workspace| {
let output = self.output_workspaces.output_for_workspace(workspace.id);
let active =
output.is_some_and(|output| self.output_workspaces.active_workspace(output) == Some(workspace.id));
WorkspaceSnapshot {
id: workspace.id,
output,
name: workspace.name.clone(),
active,
}
.map(|view| WorkspaceSnapshot {
id: view.id,
output: Some(view.output),
name: view.index.to_string(),
index: view.index,
window_count: view.window_count as u32,
visible: view.visible,
focused: view.focused,
})
.collect()
}
@@ -456,7 +453,10 @@ impl ShellSnapshot {
output_hi,
output_lo,
workspace.name.clone(),
u32::from(workspace.active),
workspace.index,
workspace.window_count,
u32::from(workspace.visible),
u32::from(workspace.focused),
);
}
for snapshot in &self.windows {
@@ -499,7 +499,10 @@ struct WorkspaceSnapshot {
id: WorkspaceId,
output: Option<OutputId>,
name: String,
active: bool,
index: u32,
window_count: u32,
visible: bool,
focused: bool,
}
fn split_id(id: u64) -> (u32, u32) {
@@ -559,7 +562,10 @@ mod tests {
id: WorkspaceId(1),
output: Some(OutputId(1)),
name: "1".into(),
active: true,
index: 1,
window_count: 1,
visible: true,
focused: true,
}],
windows: vec![ManagedWindowSnapshot {
id: WindowId(1),
@@ -575,7 +581,10 @@ mod tests {
|s| s.outputs[0].active_workspace = WorkspaceId(2),
|s| s.outputs.clear(),
|s| s.workspaces[0].output = None,
|s| s.workspaces[0].active = false,
|s| s.workspaces[0].visible = false,
|s| s.workspaces[0].focused = false,
|s| s.workspaces[0].index = 2,
|s| s.workspaces[0].window_count = 0,
|s| s.workspaces[0].name = "renamed".into(),
|s| s.windows[0].workspace = 2,
|s| s.windows[0].title = "new title".into(),
+2 -2
View File
@@ -1016,7 +1016,7 @@ mod tests {
let mut workspaces = WorkspaceSet::default();
let first = workspaces.active_id();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
let second = workspaces.ensure_numeric(2).unwrap();
let second = workspaces.create_workspace();
workspaces.activate(second).unwrap();
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
workspaces.insert_window(WindowId(3), Axis::Horizontal, 0.5).unwrap();
@@ -1027,7 +1027,7 @@ mod tests {
.connect(OutputId(1), OutputGeometry::new(0, 0, 1920, 1080), first)
.unwrap();
let owner = if assignment == Some(OutputId(2)) {
let third = workspaces.ensure_numeric(3).unwrap();
let third = workspaces.create_workspace();
outputs
.connect(OutputId(2), OutputGeometry::new(1920, 0, 1920, 1080), third)
.unwrap();
+1 -1
View File
@@ -313,7 +313,7 @@ impl Ferese {
})
.collect::<HashSet<_>>();
self.unmap_invisible_windows(&visible_windows);
self.prune_empty_workspaces(&visible);
self.reconcile_workspaces(&visible);
self.send_shell_snapshots();
active_animation = true;
}
+6 -6
View File
@@ -15,11 +15,11 @@ impl Ferese {
visible
}
pub(super) fn prune_empty_workspaces(&mut self, visible: &HashSet<WorkspaceId>) {
let mut protected = visible.clone();
protected.extend(self.output_workspaces.history_workspaces());
for workspace in self.workspaces.prune_empty(&protected) {
self.output_workspaces.forget_workspace(workspace);
pub(super) fn reconcile_workspaces(&mut self, visible: &HashSet<WorkspaceId>) {
for workspace in self
.output_workspaces
.reconcile_workspaces(&mut self.workspaces, visible)
{
self.viewport_animations.remove(&workspace);
}
}
@@ -52,7 +52,7 @@ impl Ferese {
pub fn relayout(&mut self) {
self.refresh_input_capture_zones();
let visible_workspaces = self.visible_workspace_ids();
self.prune_empty_workspaces(&visible_workspaces);
self.reconcile_workspaces(&visible_workspaces);
if self.session_lock.active {
self.configure_lock_surfaces();
}
+12 -18
View File
@@ -394,16 +394,13 @@ impl Ferese {
slide_direction: Option<SwipeDirection>,
auto_back_and_forth: bool,
) {
let workspace = match self.workspaces.ensure_numeric(index) {
Ok(workspace) => workspace,
Err(error) => {
tracing::error!(%error, index, "failed to switch workspace");
return;
}
};
let Some(output) = self.output_workspaces.focused_output() else {
return;
};
let Some(workspace) = self.output_workspaces.workspace_at(&self.workspaces, output, index) else {
return;
};
let previous = self.output_workspaces.active_workspace(output);
let owner = match self
.output_workspaces
@@ -526,27 +523,24 @@ impl Ferese {
let Some(window) = self.focused_window else {
return;
};
let Some(output) = self.output_workspaces.focused_output() else {
return;
};
let Some(destination) = self.output_workspaces.workspace_at(&self.workspaces, output, index) else {
return;
};
let axis = self
.output_bounds()
.and_then(|bounds| {
let workspace = self.workspaces.ensure_numeric(index).ok()?;
let target = self.workspaces.workspace(workspace)?;
let target = self.workspaces.workspace(destination)?;
target.layout.automatic_axis(target.last_focused, bounds).ok()
})
.unwrap_or(Axis::Horizontal);
let destination = match self.workspaces.move_window_to_numeric(window, index, axis, 0.5) {
Ok(destination) => destination,
Err(error) => {
if let Err(error) = self.workspaces.move_window_to_workspace(window, destination, axis, 0.5) {
tracing::error!(%error, ?window, index, "failed to move window to workspace");
return;
}
};
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.relayout();
+11 -13
View File
@@ -15,25 +15,23 @@ impl Ferese {
self.next_output_id = self.next_output_id.saturating_add(1);
id
});
let fallback_workspace = (1..)
.find_map(|index| {
let workspace = self.workspaces.ensure_numeric(index).ok()?;
self.output_workspaces
.output_for_workspace(workspace)
.is_none()
.then_some(workspace)
})
.expect("numeric workspaces are inexhaustible");
let fallback_workspace = self
.workspaces
.iter()
.filter(|workspace| self.output_workspaces.output_for_workspace(workspace.id).is_none())
.map(|workspace| workspace.id)
.min_by_key(|id| id.0)
.unwrap_or_else(|| self.workspaces.create_workspace());
let geometry = OutputGeometry::new(geometry.loc.x, geometry.loc.y, geometry.size.w, geometry.size.h);
let registration = self.output_workspaces.connect(output_id, geometry, fallback_workspace);
match registration {
Ok(workspace) => {
Ok(_) => {
self.output_ids.insert(output.clone(), output_id);
if self.output_workspaces.focused_output() == Some(output_id) {
self.activate_output_workspace(output_id, workspace);
}
self.restore_output_focus();
let visible = self.visible_workspace_ids();
self.reconcile_workspaces(&visible);
self.send_shell_snapshots();
}
Err(error) => {
+8 -3
View File
@@ -287,10 +287,15 @@ impl Ferese {
.automatic_axis(self.focused_window, bounds)
.unwrap_or(Axis::Horizontal);
if let Some(workspace) = rule.workspace
&& let Err(error) = self.workspaces.move_window_to_numeric(id, workspace, axis, 0.5)
if let Some(index) = rule.workspace
&& let Some(output) = self
.workspaces
.workspace_for_window(id)
.and_then(|workspace| self.output_workspaces.output_for_workspace(workspace))
&& let Some(workspace) = self.output_workspaces.workspace_at(&self.workspaces, output, index)
&& let Err(error) = self.workspaces.move_window_to_workspace(id, workspace, axis, 0.5)
{
tracing::warn!(%error, ?id, workspace, "failed to apply window workspace rule");
tracing::warn!(%error, ?id, ?workspace, "failed to apply window workspace rule");
}
let bounds = self.floating_bounds_for_window(id).unwrap_or(bounds);
+2 -1
View File
@@ -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;
+29 -31
View File
@@ -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();
+74 -139
View File
@@ -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()
}
}
+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
A single-output session starts with workspace 1. Numbered workspace shortcuts create
workspaces on demand; switching forward past the last workspace also creates the
next one when the current workspace contains windows. Empty workspaces are
cleaned up automatically, keeping at most one spare empty workspace in addition to
visible workspaces and each monitor's previous workspace. Additional displays
receive their own workspace.
Each monitor has an independent list of workspaces, numbered from 1. Its bar,
overview strip, and numeric shortcuts use that same order. Numbers change when
empty workspaces are removed; internal workspace IDs stay stable.
`Super+Escape` returns to the previous workspace on the focused monitor. Repeat
it to switch between the two most recently visited workspaces. Switching to a
workspace on another monitor updates that monitor's history. History is cleared
for a disconnected monitor; it does not jump to a workspace reclaimed by another
monitor on reconnect. With no previous workspace, the action does nothing.
One empty workspace is always available at the end of each monitor's list.
Opening or moving a window there creates another spare. An empty workspace stays
while displayed or participating in a swipe, then disappears after you leave it.
Selecting a number beyond the current list selects the trailing empty workspace.
Window-rule workspace numbers also refer to positions on the window's monitor.
`Super+Escape` returns to the previous surviving workspace on the focused monitor.
History does not keep abandoned empty workspaces alive. With no previous workspace,
the action does nothing. Disconnecting a monitor clears its history; occupied
workspaces move to another monitor and can return when it reconnects.
Enable **Settings → Windows → Toggle back with the same workspace shortcut**, or:
+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_lo" type="uint" summary="zero when the workspace has no output"/>
<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 name="snapshot_end">
@@ -1,7 +1,7 @@
"""Workspace history against a temporary nested compositor.
FERESE_TEST_WORKSPACE_HISTORY=1 python3 scripts/tests/test_workspace_history_isolated.py
Requires target/debug/ferese, feresectl, dbus-daemon, and a Wayland host.
Requires target/debug/ferese, feresectl, foot, dbus-daemon, and a Wayland host.
"""
import json
import os
@@ -15,7 +15,7 @@ import unittest
@unittest.skipUnless(os.environ.get("FERESE_TEST_WORKSPACE_HISTORY") == "1", "nested workspace test is opt-in")
class WorkspaceHistoryTest(unittest.TestCase):
def test_empty_workspace_history_and_explicit_ipc_selection(self):
def test_dynamic_positions_spare_cleanup_and_history(self):
repo = Path(__file__).resolve().parents[2]
with tempfile.TemporaryDirectory(prefix="ferese-workspace-test-") as temporary:
root = Path(temporary)
@@ -59,28 +59,73 @@ class WorkspaceHistoryTest(unittest.TestCase):
if compositor.poll() is not None or time.monotonic() >= deadline:
self.fail((root / "compositor.log").read_text())
time.sleep(.025)
self.assertEqual(active(), "1")
command("workspace-back-and-forth")
self.assertEqual(active(), "1", "no history is a no-op")
def wait_for(predicate):
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
value = predicate()
if value:
return value
time.sleep(.025)
self.fail("Timed out: " + (root / "compositor.log").read_text())
def workspaces():
return command("get-workspaces")
initial = workspaces()[0]["id"]
self.assertEqual(len(workspaces()), 1)
command("workspace", "9")
self.assertEqual(workspaces()[0]["id"], initial)
self.assertEqual(len(workspaces()), 1, "numeric lookup must not create gaps")
env["WAYLAND_DISPLAY"] = str(next(
path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")
))
first = launch(["foot", "--app-id", "ferese.test.workspace.first", "sleep", "120"],
stdout=log, stderr=log)
wait_for(lambda: len(workspaces()) == 2 and workspaces()[0]["window_count"] == 1)
command("workspace", "2")
self.assertEqual(active(), "2")
self.assertEqual({w["name"] for w in command("get-workspaces")}, {"1", "2"})
second_id = next(w["id"] for w in workspaces() if w["focused"])
second = launch(["foot", "--app-id", "ferese.test.workspace.second", "sleep", "120"],
stdout=log, stderr=log)
wait_for(lambda: len(workspaces()) == 3 and workspaces()[1]["window_count"] == 1)
for expected in ["1", "2", "1", "2"]:
command("workspace-back-and-forth")
self.assertEqual(active(), expected)
command("workspace", "3")
self.assertEqual({w["name"] for w in command("get-workspaces")}, {"2", "3"},
"old empty history is pruned")
os.killpg(first.pid, signal.SIGTERM)
first.wait(timeout=3)
wait_for(lambda: len(workspaces()) == 2)
self.assertEqual(workspaces()[0]["id"], second_id, "compaction preserves identity")
self.assertEqual([w["index"] for w in workspaces()], [1, 2])
command("workspace-back-and-forth")
self.assertEqual(active(), "1", "removed history cannot resurrect an empty workspace")
spare_id = workspaces()[1]["id"]
command("workspace", "99")
self.assertEqual(active(), "2")
self.assertEqual(next(w["id"] for w in workspaces() if w["focused"]), spare_id)
command("workspace-back-and-forth")
self.assertEqual(active(), "1")
replacement = source.with_suffix(".new")
replacement.write_text("animations { enabled #false; }\nworkspaces { auto-back-and-forth #true; }\n")
replacement.replace(source)
command("reload-config")
command("workspace", "3")
self.assertEqual(active(), "3", "literal IPC selection must not auto-toggle")
command("workspace", "1")
self.assertEqual(active(), "1", "explicit IPC selection must not auto-toggle")
command("workspace-back-and-forth")
self.assertEqual(active(), "2", "reload must preserve history")
self.assertEqual(active(), "2")
command("workspace-back-and-forth")
self.assertEqual(active(), "3")
self.assertEqual(active(), "1")
os.killpg(second.pid, signal.SIGTERM)
second.wait(timeout=3)
wait_for(lambda: all(w["window_count"] == 0 for w in workspaces()))
command("workspace", "2")
wait_for(lambda: len(workspaces()) == 1)
self.assertEqual(workspaces()[0]["id"], spare_id)
self.assertEqual(workspaces()[0]["name"], "1")
self.assertTrue(workspaces()[0]["visible"])
self.assertTrue(workspaces()[0]["focused"])
finally:
for child in reversed(children):
if child.poll() is None:
+14 -9
View File
@@ -38,20 +38,19 @@ impl FereseShell {
.spacing(1)
.align_y(cosmic::iced::Alignment::Center);
for workspace in &self.snapshot.workspaces {
for workspace in self
.snapshot
.workspaces_for_output(focused_output.map(|output| output.id))
{
let active = workspace_active_on_bar(workspace.id, focused_output);
let owner = workspace
.output
.and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id));
let occupied = self
.snapshot
.windows
.iter()
.any(|window| window.workspace == workspace.id);
let occupied = workspace.window_count > 0;
let indicator = workspace_indicator(
&workspace.name,
active,
workspace.active && !active,
workspace.visible && !active,
occupied,
bar,
shell_theme,
@@ -59,8 +58,14 @@ impl FereseShell {
let workspace_button = button::custom(indicator)
.name(format!(
"Workspace {}{}{}",
workspace.name,
if active { ", active" } else { "" },
workspace.index,
if workspace.focused {
", focused"
} else if active {
", visible"
} else {
""
},
owner.map_or(String::new(), |output| format!(", on {}", output.name))
))
.height(bar.control_height)
+58 -6
View File
@@ -19,6 +19,14 @@ pub(crate) struct ShellSnapshot {
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)]
pub(crate) struct OutputSnapshot {
pub(crate) id: u64,
@@ -32,7 +40,10 @@ pub(crate) struct WorkspaceSnapshot {
pub(crate) id: u64,
pub(crate) name: String,
pub(crate) output: Option<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)]
@@ -249,7 +260,10 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
output_hi,
output_lo,
name,
active,
index,
window_count,
visible,
focused,
} => {
let output = join_id(output_hi, output_lo);
@@ -257,7 +271,10 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
id: join_id(workspace_hi, workspace_lo),
name,
output: (output != 0).then_some(output),
active: active != 0,
index,
window_count,
visible: visible != 0,
focused: focused != 0,
});
}
ferese_shell_v1::Event::Window {
@@ -286,9 +303,6 @@ impl Dispatch<FereseShellV1, ()> for ControlState {
});
}
ferese_shell_v1::Event::SnapshotEnd { serial } if state.serial.take() == Some(serial) => {
state.pending.workspaces.sort_unstable_by_key(|workspace| {
ferese_core::workspace_order_key(&workspace.name, ferese_core::WorkspaceId(workspace.id))
});
let _ = state.sender.send(ControlUpdate::Snapshot(state.pending.clone()));
}
ferese_shell_v1::Event::LogoutRequested { serial, output_name } => {
@@ -348,3 +362,41 @@ mod tests {
assert!(config.is_none());
}
}
#[cfg(test)]
mod workspace_tests {
use super::*;
#[test]
fn each_bar_uses_its_own_order_and_authoritative_occupancy() {
let workspace = |id, output, index, window_count| WorkspaceSnapshot {
id,
output: Some(output),
index,
name: index.to_string(),
window_count,
visible: index == 1,
focused: output == 1 && index == 1,
};
let snapshot = ShellSnapshot {
workspaces: vec![workspace(7, 1, 1, 2), workspace(9, 1, 2, 0), workspace(3, 2, 1, 0)],
..Default::default()
};
let left = snapshot.workspaces_for_output(Some(1)).collect::<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);
}
}