Unify per-monitor workspace numbering and lifecycle policy
This commit is contained in:
1 parent
07bda5a21c
commit
1441875096
20 files changed
+705
-314
No files matched your search
+7
-28
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,27 +523,24 @@ 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,
|
|
||||||
Err(error) => {
|
|
||||||
tracing::error!(%error, ?window, index, "failed to move window to workspace");
|
tracing::error!(%error, ?window, index, "failed to move window to workspace");
|
||||||
return;
|
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;
|
||||||
self.relayout();
|
self.relayout();
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user