From 17b84824338dd095237571db53e991383ddf6eb3 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 1 Oct 2026 10:14:35 +0100 Subject: [PATCH] Add per-monitor workspace back-and-forth --- compositor/src/config.rs | 50 +++++- compositor/src/input.rs | 3 +- compositor/src/ipc.rs | 1 + compositor/src/state.rs | 41 ++++- crates/ferese-core/src/output.rs | 159 +++++++++++++++++- docs/configuration.md | 26 ++- docs/shortcuts.md | 6 + feresectl/src/main.rs | 28 ++- packaging/config.kdl | 5 + .../tests/test_workspace_history_isolated.py | 98 +++++++++++ shell/ferese-settings/src/main.rs | 2 +- shell/ferese-settings/src/schema.rs | 6 + 12 files changed, 405 insertions(+), 20 deletions(-) create mode 100644 scripts/tests/test_workspace_history_isolated.py diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 53abc23..7e75da6 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -27,6 +27,8 @@ pub struct Config { #[serde(default)] layout: LayoutConfig, #[serde(default)] + workspaces: WorkspacesConfig, + #[serde(default)] input: InputConfig, #[serde(default)] theme: ThemeConfig, @@ -315,6 +317,7 @@ impl Config { lock_idle: self.lock_screen.validate()?, autostart: self.autostart.clone(), layout_mode: self.layout_mode(), + workspace_auto_back_and_forth: self.workspaces.auto_back_and_forth, gap_config: self.gap_config()?, input_settings, bindings, @@ -500,6 +503,7 @@ pub enum BindingAction { Resize(ferese_layout::Direction), SwitchWorkspace(u8), SwitchRelativeWorkspace(bool), + WorkspaceBackAndForth, MoveToWorkspace(u8), ToggleFullscreen, ToggleMaximized, @@ -598,6 +602,7 @@ impl Binding { BindingAction::Move(direction) => format!("Move window {direction:?}"), BindingAction::Resize(direction) => format!("Resize window {direction:?}"), BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"), + BindingAction::WorkspaceBackAndForth => "Return to last workspace on this monitor".into(), BindingAction::MoveToWorkspace(index) => format!("Move window to workspace {index}"), BindingAction::SwitchRelativeWorkspace(next) => { if *next { "Next workspace" } else { "Previous workspace" }.into() @@ -818,6 +823,12 @@ impl Default for SpringSettings { } } +#[derive(Debug, Default, Deserialize)] +struct WorkspacesConfig { + #[serde(default)] + auto_back_and_forth: bool, +} + #[derive(Debug, Deserialize)] struct LayoutConfig { mode: Option, @@ -1495,6 +1506,10 @@ fn parse_action( no_argument()?; Ok(BindingAction::SwitchRelativeWorkspace(action == "workspace-next")) } + "workspace-back-and-forth" => { + no_argument()?; + Ok(BindingAction::WorkspaceBackAndForth) + } "spawn" => { let command = required_argument()?; let argv = commands @@ -1591,6 +1606,7 @@ fn default_bindings() -> Vec { binding("Super+]", "expel", None), binding("Super+Shift+Space", "toggle-floating", None), binding("Super+Tab", "toggle-overview", None), + binding("Super+Escape", "workspace-back-and-forth", None), binding("Super+Shift+E", "exit", None), ]; @@ -1872,6 +1888,34 @@ pub(crate) fn config_path() -> Option { mod tests { use super::*; + #[test] + fn workspace_back_and_forth_config_is_optional_and_bindable() { + let defaults = Config::default().runtime_config().unwrap(); + assert!(!defaults.workspace_auto_back_and_forth); + assert!( + defaults + .bindings + .iter() + .any(|binding| binding.action == BindingAction::WorkspaceBackAndForth) + ); + let configured = parse( + "workspaces {\n auto-back-and-forth #true\n}\nbinding \"Super+BackSpace\" \"workspace-back-and-forth\"\n", + ) + .runtime_config() + .unwrap(); + assert!(configured.workspace_auto_back_and_forth); + assert_eq!( + configured + .bindings + .iter() + .filter(|binding| binding.action == BindingAction::WorkspaceBackAndForth) + .count(), + 2 + ); + assert!(parse_action("workspace-back-and-forth", Some("2"), &HashMap::new()).is_err()); + assert!(Config::parse_source("workspaces {\n auto-back-and-forth \"yes\"\n}\n").is_err()); + } + #[test] fn packaged_and_custom_kdl_pass_runtime_validation() { Config::parse_source(include_str!("../../packaging/config.kdl")) @@ -2328,7 +2372,7 @@ mod tests { let input = config.input_settings().unwrap(); let bindings = config.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 48); + assert_eq!(bindings.len(), 49); for (shift, action) in [ (false, BindingAction::ToggleMaximized), (true, BindingAction::ToggleFullscreen), @@ -2371,14 +2415,14 @@ mod tests { let input = replaced.input_settings().unwrap(); let bindings = replaced.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 48); + assert_eq!(bindings.len(), 49); assert!(bindings.iter().any(|binding| { binding.action == BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()]) })); let input = unbound.input_settings().unwrap(); let bindings = unbound.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 47); + assert_eq!(bindings.len(), 48); assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close)); } diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 5c1a008..c65dafa 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -987,8 +987,9 @@ impl Ferese { BindingAction::Move(direction) => self.move_direction(direction), BindingAction::Resize(direction) => self.resize_direction(direction), BindingAction::SwitchRelativeWorkspace(next) => self.switch_relative_workspace(next, None), + BindingAction::WorkspaceBackAndForth => self.workspace_back_and_forth(), BindingAction::SwitchWorkspace(workspace) => { - self.switch_workspace(u32::from(workspace)); + self.switch_workspace_from_binding(u32::from(workspace)); } BindingAction::MoveToWorkspace(workspace) => { self.move_focused_to_workspace(u32::from(workspace)); diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 7ddbc57..0375f9b 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -650,6 +650,7 @@ impl Ferese { "move" => self.move_direction(direction_arg(args)?), "resize" => self.resize_direction(direction_arg(args)?), "workspace" => self.switch_workspace(workspace_arg(args)?), + "workspace-back-and-forth" => self.workspace_back_and_forth(), "move-to-workspace" => self.move_focused_to_workspace(workspace_arg(args)?), "toggle-floating" => self.toggle_focused_floating(), "toggle-fullscreen" => self.toggle_focused_fullscreen(), diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 2462cbc..797054f 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -276,6 +276,7 @@ pub struct Ferese { gap_config: GapConfig, pub(crate) input_settings: InputSettings, pub(crate) bindings: Vec, + workspace_auto_back_and_forth: bool, window_rules: Vec, window_rules_applied: HashSet, pub(crate) theme_settings: ThemeSettings, @@ -354,6 +355,7 @@ pub struct RuntimeConfig { pub gap_config: GapConfig, pub input_settings: InputSettings, pub bindings: Vec, + pub workspace_auto_back_and_forth: bool, pub window_rules: Vec, pub theme_settings: ThemeSettings, pub default_column_width: ColumnWidth, @@ -495,6 +497,7 @@ impl Ferese { gap_config: config.gap_config, input_settings: config.input_settings, bindings: config.bindings, + workspace_auto_back_and_forth: config.workspace_auto_back_and_forth, window_rules: config.window_rules, window_rules_applied: HashSet::new(), theme_settings: config.theme_settings, @@ -631,6 +634,7 @@ impl Ferese { self.gap_config = config.gap_config; self.input_settings = config.input_settings; self.bindings = config.bindings; + self.workspace_auto_back_and_forth = config.workspace_auto_back_and_forth; self.portal_shortcuts.reconcile(&self.bindings, &self.input_settings); let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules); self.theme_settings = config.theme_settings; @@ -1505,7 +1509,9 @@ impl Ferese { } fn prune_empty_workspaces(&mut self, visible: &HashSet) { - for workspace in self.workspaces.prune_empty(visible) { + 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); self.viewport_animations.remove(&workspace); } @@ -2768,14 +2774,32 @@ impl Ferese { } pub fn switch_workspace(&mut self, index: u32) { - self.switch_workspace_internal(index, None); + self.switch_workspace_internal(index, None, false); + } + + pub(crate) fn switch_workspace_from_binding(&mut self, index: u32) { + self.switch_workspace_internal(index, None, self.workspace_auto_back_and_forth); + } + + pub(crate) fn workspace_back_and_forth(&mut self) { + let Some(output) = self.output_workspaces.focused_output() else { + return; + }; + if let Some(workspace) = self.output_workspaces.previous_workspace(output) { + self.activate_managed_workspace(workspace); + } } pub(crate) fn switch_workspace_from_swipe(&mut self, index: u32, direction: SwipeDirection) { - self.switch_workspace_internal(index, Some(direction)); + self.switch_workspace_internal(index, Some(direction), false); } - fn switch_workspace_internal(&mut self, index: u32, slide_direction: Option) { + fn switch_workspace_internal( + &mut self, + index: u32, + slide_direction: Option, + auto_back_and_forth: bool, + ) { let workspace = match self.workspaces.ensure_numeric(index) { Ok(workspace) => workspace, Err(error) => { @@ -2787,7 +2811,10 @@ impl Ferese { return; }; let previous = self.output_workspaces.active_workspace(output); - let owner = match self.output_workspaces.switch_workspace(output, workspace) { + let owner = match self + .output_workspaces + .select_workspace(output, workspace, auto_back_and_forth) + { Ok(ferese_core::WorkspaceSwitch::Activated(output)) | Ok(ferese_core::WorkspaceSwitch::FocusedExisting(output)) => output, Err(error) => { @@ -2795,6 +2822,10 @@ impl Ferese { return; } }; + let workspace = self + .output_workspaces + .active_workspace(owner) + .expect("workspace owner is connected"); self.update_workspace_slide(output, owner, previous, workspace, slide_direction); self.activate_output_workspace(owner, workspace); diff --git a/crates/ferese-core/src/output.rs b/crates/ferese-core/src/output.rs index 9aec84c..e556662 100644 --- a/crates/ferese-core/src/output.rs +++ b/crates/ferese-core/src/output.rs @@ -59,9 +59,19 @@ pub enum WorkspaceSwitch { struct OutputState { geometry: OutputGeometry, active: WorkspaceId, + previous: Option, workspaces: HashSet, } +impl OutputState { + fn activate(&mut self, workspace: WorkspaceId) { + if self.active != workspace { + self.previous = self.workspaces.contains(&self.active).then_some(self.active); + self.active = workspace; + } + } +} + #[derive(Clone, Copy, Debug)] struct EvacuatedWorkspace { workspace: WorkspaceId, @@ -93,6 +103,14 @@ impl OutputWorkspaceMap { self.outputs.get(&output).map(|state| state.active) } + pub fn previous_workspace(&self, output: OutputId) -> Option { + self.outputs.get(&output).and_then(|state| state.previous) + } + + pub fn history_workspaces(&self) -> impl Iterator + '_ { + self.outputs.values().filter_map(|state| state.previous) + } + pub fn output_for_workspace(&self, workspace: WorkspaceId) -> Option { self.assignments.get(&workspace).copied() } @@ -105,6 +123,9 @@ impl OutputWorkspaceMap { && let Some(output) = self.outputs.get_mut(&owner) { output.workspaces.remove(&workspace); + if output.previous == Some(workspace) { + output.previous = None; + } } self.revisions.remove(&workspace); self.evacuations.retain(|_, record| { @@ -154,6 +175,9 @@ impl OutputWorkspaceMap { && let Some(target_state) = self.outputs.get_mut(&target) { target_state.workspaces.remove(&evacuated.workspace); + if target_state.previous == Some(evacuated.workspace) { + target_state.previous = None; + } if target_state.active == evacuated.workspace && let Some(active) = target_state .workspaces @@ -161,7 +185,7 @@ impl OutputWorkspaceMap { .copied() .min_by_key(|workspace| workspace.0) { - target_state.active = active; + target_state.activate(active); } } self.assignments.insert(evacuated.workspace, output); @@ -194,6 +218,7 @@ impl OutputWorkspaceMap { OutputState { geometry, active, + previous: None, workspaces: reclaimed.into_iter().collect(), }, ); @@ -229,7 +254,7 @@ impl OutputWorkspaceMap { self.outputs .get_mut(&target) .expect("migration target is connected") - .active = removed.active; + .activate(removed.active); } } @@ -276,14 +301,14 @@ impl OutputWorkspaceMap { self.outputs .get_mut(&owner) .expect("workspace owner is connected") - .active = workspace; + .activate(workspace); return Ok(WorkspaceSwitch::FocusedExisting(owner)); } let reassigned = self.assignments.insert(workspace, output) != Some(output); let state = self.outputs.get_mut(&output).expect("output was checked above"); state.workspaces.insert(workspace); - state.active = workspace; + state.activate(workspace); self.focused = Some(output); if reassigned { self.bump_revision(workspace); @@ -293,6 +318,20 @@ impl OutputWorkspaceMap { Ok(WorkspaceSwitch::Activated(output)) } + pub fn select_workspace( + &mut self, + output: OutputId, + workspace: WorkspaceId, + auto_back_and_forth: bool, + ) -> Result { + let workspace = if auto_back_and_forth && self.active_workspace(output) == Some(workspace) { + self.previous_workspace(output).unwrap_or(workspace) + } else { + workspace + }; + self.switch_workspace(output, workspace) + } + pub fn assign_workspace(&mut self, output: OutputId, workspace: WorkspaceId) -> Result { if !self.outputs.contains_key(&output) { return Err(OutputError::UnknownOutput(output)); @@ -351,6 +390,12 @@ impl OutputWorkspaceMap { if !state.workspaces.contains(&state.active) { return false; } + if state + .previous + .is_some_and(|previous| previous == state.active || !state.workspaces.contains(&previous)) + { + return false; + } for workspace in &state.workspaces { if self.assignments.get(workspace) != Some(output) { return false; @@ -406,6 +451,112 @@ mod tests { OutputGeometry::new(x, 0, 1_920, 1_080) } + #[test] + fn history_tracks_actual_switches_and_toggles_without_replacing_it_on_a_noop() { + let mut outputs = OutputWorkspaceMap::default(); + let display = OutputId(1); + outputs.connect(display, geometry(0), WorkspaceId(1)).unwrap(); + assert_eq!(outputs.previous_workspace(display), None); + outputs.select_workspace(display, WorkspaceId(1), true).unwrap(); + assert_eq!(outputs.previous_workspace(display), None); + outputs.switch_workspace(display, WorkspaceId(2)).unwrap(); + outputs.switch_workspace(display, WorkspaceId(2)).unwrap(); + assert_eq!(outputs.previous_workspace(display), Some(WorkspaceId(1))); + outputs.select_workspace(display, WorkspaceId(2), false).unwrap(); + assert_eq!(outputs.active_workspace(display), Some(WorkspaceId(2))); + for expected in [WorkspaceId(1), WorkspaceId(2), WorkspaceId(1)] { + let current = outputs.active_workspace(display).unwrap(); + outputs.select_workspace(display, current, true).unwrap(); + assert_eq!(outputs.active_workspace(display), Some(expected)); + assert_eq!(outputs.previous_workspace(display), Some(current)); + } + assert!(outputs.validate()); + } + + #[test] + fn another_monitors_workspace_updates_only_its_owners_history() { + let mut outputs = OutputWorkspaceMap::default(); + let left = OutputId(1); + let right = OutputId(2); + outputs.connect(left, geometry(0), WorkspaceId(1)).unwrap(); + outputs.connect(right, geometry(1920), WorkspaceId(2)).unwrap(); + outputs.switch_workspace(left, WorkspaceId(3)).unwrap(); + outputs.switch_workspace(right, WorkspaceId(4)).unwrap(); + assert_eq!( + outputs.select_workspace(left, WorkspaceId(2), true), + Ok(WorkspaceSwitch::FocusedExisting(right)) + ); + assert_eq!(outputs.active_workspace(left), Some(WorkspaceId(3))); + assert_eq!(outputs.previous_workspace(left), Some(WorkspaceId(1))); + assert_eq!(outputs.previous_workspace(right), Some(WorkspaceId(4))); + let previous = outputs.previous_workspace(right).unwrap(); + outputs.switch_workspace(right, previous).unwrap(); + assert_eq!(outputs.active_workspace(right), Some(WorkspaceId(4))); + assert_eq!(outputs.previous_workspace(right), Some(WorkspaceId(2))); + assert!(outputs.validate()); + } + + #[test] + fn reconnect_clears_history_for_workspaces_reclaimed_by_another_output() { + let mut outputs = OutputWorkspaceMap::default(); + let left = OutputId(1); + let right = OutputId(2); + outputs.connect(left, geometry(0), WorkspaceId(1)).unwrap(); + outputs.connect(right, geometry(1920), WorkspaceId(2)).unwrap(); + outputs.focus_output(right).unwrap(); + outputs.disconnect(right).unwrap(); + assert_eq!(outputs.previous_workspace(left), Some(WorkspaceId(1))); + outputs.switch_workspace(left, WorkspaceId(1)).unwrap(); + assert_eq!(outputs.previous_workspace(left), Some(WorkspaceId(2))); + outputs.connect(right, geometry(1920), WorkspaceId(3)).unwrap(); + assert_eq!(outputs.previous_workspace(left), None); + assert_eq!(outputs.previous_workspace(right), None); + assert!(outputs.validate()); + } + + #[test] + fn forgetting_a_previous_workspace_clears_its_history() { + let mut outputs = OutputWorkspaceMap::default(); + let display = OutputId(1); + outputs.connect(display, geometry(0), WorkspaceId(1)).unwrap(); + outputs.switch_workspace(display, WorkspaceId(2)).unwrap(); + assert!(outputs.forget_workspace(WorkspaceId(1))); + assert_eq!(outputs.previous_workspace(display), None); + outputs.select_workspace(display, WorkspaceId(2), true).unwrap(); + assert_eq!(outputs.active_workspace(display), Some(WorkspaceId(2))); + assert_eq!( + outputs.select_workspace(OutputId(99), WorkspaceId(2), true), + Err(OutputError::UnknownOutput(OutputId(99))) + ); + } + + #[test] + fn empty_previous_workspace_survives_pruning_until_history_moves_on() { + let mut workspaces = crate::WorkspaceSet::default(); + let first = workspaces.active_id(); + let second = workspaces.ensure_numeric(2).unwrap(); + let third = workspaces.ensure_numeric(3).unwrap(); + let mut outputs = OutputWorkspaceMap::default(); + let display = OutputId(1); + outputs.connect(display, geometry(0), first).unwrap(); + outputs.switch_workspace(display, second).unwrap(); + workspaces.activate(second).unwrap(); + let protected = outputs.history_workspaces().chain([second]).collect(); + let removed = workspaces.prune_empty(&protected); + assert!(removed.contains(&third)); + assert!(workspaces.workspace(first).is_some()); + let third = workspaces.ensure_numeric(3).unwrap(); + outputs.switch_workspace(display, third).unwrap(); + workspaces.activate(third).unwrap(); + let protected = outputs.history_workspaces().chain([third]).collect(); + for workspace in workspaces.prune_empty(&protected) { + outputs.forget_workspace(workspace); + } + assert!(workspaces.workspace(first).is_none()); + assert!(workspaces.workspace(second).is_some()); + assert_eq!(outputs.previous_workspace(display), Some(second)); + } + #[test] fn new_outputs_receive_distinct_workspaces() { let mut outputs = OutputWorkspaceMap::default(); diff --git a/docs/configuration.md b/docs/configuration.md index 482183c..c414a7d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -206,10 +206,30 @@ theme { 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 empty workspace in addition to -any empty workspace currently shown on another display. Additional displays +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 +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. + +Enable **Settings → Windows → Toggle back with the same workspace shortcut**, or: + +```kdl +workspaces { + auto-back-and-forth #true +} +``` + +This defaults to `#false`. When enabled, pressing a `workspace` binding for the +current workspace returns to the previous one. Explicit `feresectl workspace N` +commands and overview selection still select the requested workspace directly. +Use `feresectl workspace-back-and-forth` for an explicit toggle, or bind +`workspace-back-and-forth` to another key or gesture. The action takes no argument. + ## Input | `input` key | Type | Default | Meaning | @@ -289,6 +309,7 @@ invalid. A disabled binding must omit `action` and `argument`. | `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` | | `workspace`, `move-to-workspace` | Workspace number string, 1–255 | | `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor | +| `workspace-back-and-forth` | None; return to the previous workspace on this monitor | | `none` | None; ignore this trigger | | `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-floating`, `toggle-overview` | None | @@ -304,6 +325,7 @@ Super+Shift+H/J/K/L move; Super+Ctrl+H/J/K/L resize; Super+1–9 workspace; Super+Shift+1–9 move to workspace; Super+R width cycle; Super+C center; Super+[/] consume/expel; Super+F maximize; Super+Shift+F fullscreen; Super+M layout; Super+Shift+Space floating; Super+Tab overview; +Super+Escape previous visited workspace; Super+Shift+S area screenshot; Print Screen whole-screen screenshot; Super+Shift+E logout confirmation. diff --git a/docs/shortcuts.md b/docs/shortcuts.md index dab7dd1..a236d00 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -56,10 +56,16 @@ overview. | --- | --- | | Super + 1–9 | Switch to that workspace | | Super + Shift + 1–9 | Move the window to that workspace | +| Super + Escape | Toggle between the last two visited workspaces on this monitor | Workspaces are created as needed. Selecting one visible on another monitor focuses that monitor. +To make pressing the current workspace's shortcut toggle back too, enable +**Settings → Windows → Toggle back with the same workspace shortcut**. It is off +by default. The action can also be bound as `workspace-back-and-forth` or run with +`feresectl workspace-back-and-forth`. + ## Desktop actions | Shortcut | Action | diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index ba24f02..dddd917 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -141,9 +141,24 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), )); } }, - "toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout" | "toggle-overview" - | "cycle-column-width" | "center-column" | "consume" | "expel" | "close" | "get-focused-window" - | "get-windows" | "get-workspaces" | "get-outputs" | "reload-config" | "exit" | "request-logout" => { + "toggle-floating" + | "toggle-fullscreen" + | "toggle-maximized" + | "toggle-layout" + | "toggle-overview" + | "cycle-column-width" + | "center-column" + | "consume" + | "expel" + | "close" + | "get-focused-window" + | "get-windows" + | "get-workspaces" + | "get-outputs" + | "reload-config" + | "exit" + | "request-logout" + | "workspace-back-and-forth" => { if !positional.is_empty() { return Err(format!("{command} does not accept arguments")); } @@ -172,7 +187,7 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] @@ -202,6 +217,11 @@ mod tests { parse_args(["cycle-column-width".to_owned()]).unwrap(), ("cycle-column-width".to_owned(), json!({})) ); + assert_eq!( + parse_args(["workspace-back-and-forth".into()]).unwrap(), + ("workspace-back-and-forth".into(), json!({})) + ); + assert!(parse_args(["workspace-back-and-forth".into(), "2".into()]).is_err()); } #[test] diff --git a/packaging/config.kdl b/packaging/config.kdl index cbf1b1d..04eb71f 100644 --- a/packaging/config.kdl +++ b/packaging/config.kdl @@ -68,6 +68,11 @@ layout { smart-gaps #false } +workspaces { + // Press the current workspace's shortcut again to return to the previous one. + auto-back-and-forth #false +} + scrolling { default-column-width 0.5 focus-strategy "paged" diff --git a/scripts/tests/test_workspace_history_isolated.py b/scripts/tests/test_workspace_history_isolated.py new file mode 100644 index 0000000..04a5ac0 --- /dev/null +++ b/scripts/tests/test_workspace_history_isolated.py @@ -0,0 +1,98 @@ +"""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. +""" +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import time +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): + repo = Path(__file__).resolve().parents[2] + with tempfile.TemporaryDirectory(prefix="ferese-workspace-test-") as temporary: + root = Path(temporary) + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + config = root / "config/ferese" + config.mkdir(parents=True) + source = config / "config.kdl" + source.write_text("animations { enabled #false; }\n") + display = Path(os.environ["WAYLAND_DISPLAY"]) + if not display.is_absolute(): + display = Path(os.environ["XDG_RUNTIME_DIR"]) / display + env = dict(os.environ, WAYLAND_DISPLAY=str(display), XDG_RUNTIME_DIR=str(runtime), + XDG_CONFIG_HOME=str(root / "config")) + env.pop("WAYLAND_SOCKET", None) + env.pop("FERESE_SHELL_CONTROL_SOCKET", None) + children = [] + + def launch(command, **kwargs): + child = subprocess.Popen(command, env=env, start_new_session=True, **kwargs) + children.append(child) + return child + + def command(*args): + return json.loads(subprocess.check_output( + [str(repo / "target/debug/feresectl"), *args], env=env, timeout=5 + )) + + def active(): + return next(workspace["name"] for workspace in command("get-workspaces") if workspace["active"]) + + with (root / "compositor.log").open("w") as log: + try: + bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"], + stdout=subprocess.PIPE, text=True) + env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() + compositor = launch([str(repo / "target/debug/ferese"), "--backend", "nested"], + stdout=log, stderr=log) + deadline = time.monotonic() + 10 + while not (runtime / "ferese/control.sock").is_socket(): + 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") + command("workspace", "2") + self.assertEqual(active(), "2") + self.assertEqual({w["name"] for w in command("get-workspaces")}, {"1", "2"}) + 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") + 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-back-and-forth") + self.assertEqual(active(), "2", "reload must preserve history") + command("workspace-back-and-forth") + self.assertEqual(active(), "3") + finally: + for child in reversed(children): + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + try: + child.wait(timeout=3) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + child.wait(timeout=3) + if child.stdout is not None: + child.stdout.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index d3929c5..fd61775 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -928,7 +928,7 @@ impl cosmic::Application for App { ); } Page::Shortcuts => { - body = body.push(self.note("Keyboard and swipe bindings share the same actions. Use Swipe3Up, Swipe3Down, Swipe3Left, or Swipe3Right as a shortcut (also supports 4 or 5 fingers). Actions include toggle-overview, spawn, focus, move, workspace-next, workspace-previous, and none. Leave Argument empty for actions such as toggle-overview or none.")); + body = body.push(self.note("Keyboard and swipe bindings share the same actions. Use Swipe3Up, Swipe3Down, Swipe3Left, or Swipe3Right as a shortcut (also supports 4 or 5 fingers). Actions include toggle-overview, spawn, focus, move, workspace-next, workspace-previous, workspace-back-and-forth, and none. Leave Argument empty for actions such as toggle-overview or none.")); let mut gestures = row([]).spacing(6); for (keys, label) in [ ("Swipe3Up", "Customize swipe up"), diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index d2acec9..72080d6 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -824,6 +824,12 @@ pub fn fields(page: Page) -> Vec { ), ], Page::Windows => vec![ + toggle( + "workspaces.auto_back_and_forth", + "Toggle back with the same workspace shortcut", + "Press the current workspace's shortcut again to return to the previous workspace on this monitor.", + false, + ), choice( "layout.mode", "Layout for new workspaces",