Add per-monitor workspace back-and-forth

This commit is contained in:
Samuel Onoja committed 2026-10-01 10:14:35 +01:00
1 parent cb6fe73e44
commit 17b8482433
12 files changed
+405 -20

No files matched your search

+47 -3
View File
@@ -27,6 +27,8 @@ pub struct Config {
#[serde(default)] #[serde(default)]
layout: LayoutConfig, layout: LayoutConfig,
#[serde(default)] #[serde(default)]
workspaces: WorkspacesConfig,
#[serde(default)]
input: InputConfig, input: InputConfig,
#[serde(default)] #[serde(default)]
theme: ThemeConfig, theme: ThemeConfig,
@@ -315,6 +317,7 @@ impl Config {
lock_idle: self.lock_screen.validate()?, lock_idle: self.lock_screen.validate()?,
autostart: self.autostart.clone(), autostart: self.autostart.clone(),
layout_mode: self.layout_mode(), layout_mode: self.layout_mode(),
workspace_auto_back_and_forth: self.workspaces.auto_back_and_forth,
gap_config: self.gap_config()?, gap_config: self.gap_config()?,
input_settings, input_settings,
bindings, bindings,
@@ -500,6 +503,7 @@ pub enum BindingAction {
Resize(ferese_layout::Direction), Resize(ferese_layout::Direction),
SwitchWorkspace(u8), SwitchWorkspace(u8),
SwitchRelativeWorkspace(bool), SwitchRelativeWorkspace(bool),
WorkspaceBackAndForth,
MoveToWorkspace(u8), MoveToWorkspace(u8),
ToggleFullscreen, ToggleFullscreen,
ToggleMaximized, ToggleMaximized,
@@ -598,6 +602,7 @@ impl Binding {
BindingAction::Move(direction) => format!("Move window {direction:?}"), BindingAction::Move(direction) => format!("Move window {direction:?}"),
BindingAction::Resize(direction) => format!("Resize window {direction:?}"), BindingAction::Resize(direction) => format!("Resize window {direction:?}"),
BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"), 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::MoveToWorkspace(index) => format!("Move window to workspace {index}"),
BindingAction::SwitchRelativeWorkspace(next) => { BindingAction::SwitchRelativeWorkspace(next) => {
if *next { "Next workspace" } else { "Previous workspace" }.into() 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)] #[derive(Debug, Deserialize)]
struct LayoutConfig { struct LayoutConfig {
mode: Option<LayoutModeValue>, mode: Option<LayoutModeValue>,
@@ -1495,6 +1506,10 @@ fn parse_action(
no_argument()?; no_argument()?;
Ok(BindingAction::SwitchRelativeWorkspace(action == "workspace-next")) Ok(BindingAction::SwitchRelativeWorkspace(action == "workspace-next"))
} }
"workspace-back-and-forth" => {
no_argument()?;
Ok(BindingAction::WorkspaceBackAndForth)
}
"spawn" => { "spawn" => {
let command = required_argument()?; let command = required_argument()?;
let argv = commands let argv = commands
@@ -1591,6 +1606,7 @@ fn default_bindings() -> Vec<BindingConfig> {
binding("Super+]", "expel", None), binding("Super+]", "expel", None),
binding("Super+Shift+Space", "toggle-floating", None), binding("Super+Shift+Space", "toggle-floating", None),
binding("Super+Tab", "toggle-overview", None), binding("Super+Tab", "toggle-overview", None),
binding("Super+Escape", "workspace-back-and-forth", None),
binding("Super+Shift+E", "exit", None), binding("Super+Shift+E", "exit", None),
]; ];
@@ -1872,6 +1888,34 @@ pub(crate) fn config_path() -> Option<PathBuf> {
mod tests { mod tests {
use super::*; 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] #[test]
fn packaged_and_custom_kdl_pass_runtime_validation() { fn packaged_and_custom_kdl_pass_runtime_validation() {
Config::parse_source(include_str!("../../packaging/config.kdl")) Config::parse_source(include_str!("../../packaging/config.kdl"))
@@ -2328,7 +2372,7 @@ mod tests {
let input = config.input_settings().unwrap(); let input = config.input_settings().unwrap();
let bindings = config.bindings(&input).unwrap(); let bindings = config.bindings(&input).unwrap();
assert_eq!(bindings.len(), 48); assert_eq!(bindings.len(), 49);
for (shift, action) in [ for (shift, action) in [
(false, BindingAction::ToggleMaximized), (false, BindingAction::ToggleMaximized),
(true, BindingAction::ToggleFullscreen), (true, BindingAction::ToggleFullscreen),
@@ -2371,14 +2415,14 @@ mod tests {
let input = replaced.input_settings().unwrap(); let input = replaced.input_settings().unwrap();
let bindings = replaced.bindings(&input).unwrap(); let bindings = replaced.bindings(&input).unwrap();
assert_eq!(bindings.len(), 48); assert_eq!(bindings.len(), 49);
assert!(bindings.iter().any(|binding| { assert!(bindings.iter().any(|binding| {
binding.action == BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()]) binding.action == BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()])
})); }));
let input = unbound.input_settings().unwrap(); let input = unbound.input_settings().unwrap();
let bindings = unbound.bindings(&input).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)); assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
} }
+2 -1
View File
@@ -987,8 +987,9 @@ impl Ferese {
BindingAction::Move(direction) => self.move_direction(direction), BindingAction::Move(direction) => self.move_direction(direction),
BindingAction::Resize(direction) => self.resize_direction(direction), BindingAction::Resize(direction) => self.resize_direction(direction),
BindingAction::SwitchRelativeWorkspace(next) => self.switch_relative_workspace(next, None), BindingAction::SwitchRelativeWorkspace(next) => self.switch_relative_workspace(next, None),
BindingAction::WorkspaceBackAndForth => self.workspace_back_and_forth(),
BindingAction::SwitchWorkspace(workspace) => { BindingAction::SwitchWorkspace(workspace) => {
self.switch_workspace(u32::from(workspace)); self.switch_workspace_from_binding(u32::from(workspace));
} }
BindingAction::MoveToWorkspace(workspace) => { BindingAction::MoveToWorkspace(workspace) => {
self.move_focused_to_workspace(u32::from(workspace)); self.move_focused_to_workspace(u32::from(workspace));
+1
View File
@@ -650,6 +650,7 @@ impl Ferese {
"move" => self.move_direction(direction_arg(args)?), "move" => self.move_direction(direction_arg(args)?),
"resize" => self.resize_direction(direction_arg(args)?), "resize" => self.resize_direction(direction_arg(args)?),
"workspace" => self.switch_workspace(workspace_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)?), "move-to-workspace" => self.move_focused_to_workspace(workspace_arg(args)?),
"toggle-floating" => self.toggle_focused_floating(), "toggle-floating" => self.toggle_focused_floating(),
"toggle-fullscreen" => self.toggle_focused_fullscreen(), "toggle-fullscreen" => self.toggle_focused_fullscreen(),
+36 -5
View File
@@ -276,6 +276,7 @@ pub struct Ferese {
gap_config: GapConfig, gap_config: GapConfig,
pub(crate) input_settings: InputSettings, pub(crate) input_settings: InputSettings,
pub(crate) bindings: Vec<Binding>, pub(crate) bindings: Vec<Binding>,
workspace_auto_back_and_forth: bool,
window_rules: Vec<WindowRule>, window_rules: Vec<WindowRule>,
window_rules_applied: HashSet<WindowId>, window_rules_applied: HashSet<WindowId>,
pub(crate) theme_settings: ThemeSettings, pub(crate) theme_settings: ThemeSettings,
@@ -354,6 +355,7 @@ pub struct RuntimeConfig {
pub gap_config: GapConfig, pub gap_config: GapConfig,
pub input_settings: InputSettings, pub input_settings: InputSettings,
pub bindings: Vec<Binding>, pub bindings: Vec<Binding>,
pub workspace_auto_back_and_forth: bool,
pub window_rules: Vec<WindowRule>, pub window_rules: Vec<WindowRule>,
pub theme_settings: ThemeSettings, pub theme_settings: ThemeSettings,
pub default_column_width: ColumnWidth, pub default_column_width: ColumnWidth,
@@ -495,6 +497,7 @@ impl Ferese {
gap_config: config.gap_config, gap_config: config.gap_config,
input_settings: config.input_settings, input_settings: config.input_settings,
bindings: config.bindings, bindings: config.bindings,
workspace_auto_back_and_forth: config.workspace_auto_back_and_forth,
window_rules: config.window_rules, window_rules: config.window_rules,
window_rules_applied: HashSet::new(), window_rules_applied: HashSet::new(),
theme_settings: config.theme_settings, theme_settings: config.theme_settings,
@@ -631,6 +634,7 @@ impl Ferese {
self.gap_config = config.gap_config; self.gap_config = config.gap_config;
self.input_settings = config.input_settings; self.input_settings = config.input_settings;
self.bindings = config.bindings; 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); self.portal_shortcuts.reconcile(&self.bindings, &self.input_settings);
let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules); let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules);
self.theme_settings = config.theme_settings; self.theme_settings = config.theme_settings;
@@ -1505,7 +1509,9 @@ impl Ferese {
} }
fn prune_empty_workspaces(&mut self, visible: &HashSet<WorkspaceId>) { fn prune_empty_workspaces(&mut self, visible: &HashSet<WorkspaceId>) {
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.output_workspaces.forget_workspace(workspace);
self.viewport_animations.remove(&workspace); self.viewport_animations.remove(&workspace);
} }
@@ -2768,14 +2774,32 @@ impl Ferese {
} }
pub fn switch_workspace(&mut self, index: u32) { 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) { 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<SwipeDirection>) { fn switch_workspace_internal(
&mut self,
index: u32,
slide_direction: Option<SwipeDirection>,
auto_back_and_forth: bool,
) {
let workspace = match self.workspaces.ensure_numeric(index) { let workspace = match self.workspaces.ensure_numeric(index) {
Ok(workspace) => workspace, Ok(workspace) => workspace,
Err(error) => { Err(error) => {
@@ -2787,7 +2811,10 @@ impl Ferese {
return; return;
}; };
let previous = self.output_workspaces.active_workspace(output); 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::Activated(output))
| Ok(ferese_core::WorkspaceSwitch::FocusedExisting(output)) => output, | Ok(ferese_core::WorkspaceSwitch::FocusedExisting(output)) => output,
Err(error) => { Err(error) => {
@@ -2795,6 +2822,10 @@ impl Ferese {
return; 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.update_workspace_slide(output, owner, previous, workspace, slide_direction);
self.activate_output_workspace(owner, workspace); self.activate_output_workspace(owner, workspace);
+155 -4
View File
@@ -59,9 +59,19 @@ pub enum WorkspaceSwitch {
struct OutputState { struct OutputState {
geometry: OutputGeometry, geometry: OutputGeometry,
active: WorkspaceId, active: WorkspaceId,
previous: Option<WorkspaceId>,
workspaces: HashSet<WorkspaceId>, workspaces: HashSet<WorkspaceId>,
} }
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)] #[derive(Clone, Copy, Debug)]
struct EvacuatedWorkspace { struct EvacuatedWorkspace {
workspace: WorkspaceId, workspace: WorkspaceId,
@@ -93,6 +103,14 @@ impl OutputWorkspaceMap {
self.outputs.get(&output).map(|state| state.active) self.outputs.get(&output).map(|state| state.active)
} }
pub fn previous_workspace(&self, output: OutputId) -> Option<WorkspaceId> {
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()
} }
@@ -105,6 +123,9 @@ impl OutputWorkspaceMap {
&& let Some(output) = self.outputs.get_mut(&owner) && let Some(output) = self.outputs.get_mut(&owner)
{ {
output.workspaces.remove(&workspace); output.workspaces.remove(&workspace);
if output.previous == Some(workspace) {
output.previous = None;
}
} }
self.revisions.remove(&workspace); self.revisions.remove(&workspace);
self.evacuations.retain(|_, record| { self.evacuations.retain(|_, record| {
@@ -154,6 +175,9 @@ impl OutputWorkspaceMap {
&& let Some(target_state) = self.outputs.get_mut(&target) && let Some(target_state) = self.outputs.get_mut(&target)
{ {
target_state.workspaces.remove(&evacuated.workspace); target_state.workspaces.remove(&evacuated.workspace);
if target_state.previous == Some(evacuated.workspace) {
target_state.previous = None;
}
if target_state.active == evacuated.workspace if target_state.active == evacuated.workspace
&& let Some(active) = target_state && let Some(active) = target_state
.workspaces .workspaces
@@ -161,7 +185,7 @@ impl OutputWorkspaceMap {
.copied() .copied()
.min_by_key(|workspace| workspace.0) .min_by_key(|workspace| workspace.0)
{ {
target_state.active = active; target_state.activate(active);
} }
} }
self.assignments.insert(evacuated.workspace, output); self.assignments.insert(evacuated.workspace, output);
@@ -194,6 +218,7 @@ impl OutputWorkspaceMap {
OutputState { OutputState {
geometry, geometry,
active, active,
previous: None,
workspaces: reclaimed.into_iter().collect(), workspaces: reclaimed.into_iter().collect(),
}, },
); );
@@ -229,7 +254,7 @@ impl OutputWorkspaceMap {
self.outputs self.outputs
.get_mut(&target) .get_mut(&target)
.expect("migration target is connected") .expect("migration target is connected")
.active = removed.active; .activate(removed.active);
} }
} }
@@ -276,14 +301,14 @@ impl OutputWorkspaceMap {
self.outputs self.outputs
.get_mut(&owner) .get_mut(&owner)
.expect("workspace owner is connected") .expect("workspace owner is connected")
.active = workspace; .activate(workspace);
return Ok(WorkspaceSwitch::FocusedExisting(owner)); return Ok(WorkspaceSwitch::FocusedExisting(owner));
} }
let reassigned = self.assignments.insert(workspace, output) != Some(output); let reassigned = self.assignments.insert(workspace, output) != Some(output);
let state = self.outputs.get_mut(&output).expect("output was checked above"); let state = self.outputs.get_mut(&output).expect("output was checked above");
state.workspaces.insert(workspace); state.workspaces.insert(workspace);
state.active = workspace; state.activate(workspace);
self.focused = Some(output); self.focused = Some(output);
if reassigned { if reassigned {
self.bump_revision(workspace); self.bump_revision(workspace);
@@ -293,6 +318,20 @@ impl OutputWorkspaceMap {
Ok(WorkspaceSwitch::Activated(output)) Ok(WorkspaceSwitch::Activated(output))
} }
pub fn select_workspace(
&mut self,
output: OutputId,
workspace: WorkspaceId,
auto_back_and_forth: bool,
) -> Result<WorkspaceSwitch, OutputError> {
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<OutputId, OutputError> { pub fn assign_workspace(&mut self, output: OutputId, workspace: WorkspaceId) -> Result<OutputId, OutputError> {
if !self.outputs.contains_key(&output) { if !self.outputs.contains_key(&output) {
return Err(OutputError::UnknownOutput(output)); return Err(OutputError::UnknownOutput(output));
@@ -351,6 +390,12 @@ impl OutputWorkspaceMap {
if !state.workspaces.contains(&state.active) { if !state.workspaces.contains(&state.active) {
return false; return false;
} }
if state
.previous
.is_some_and(|previous| previous == state.active || !state.workspaces.contains(&previous))
{
return false;
}
for workspace in &state.workspaces { for workspace in &state.workspaces {
if self.assignments.get(workspace) != Some(output) { if self.assignments.get(workspace) != Some(output) {
return false; return false;
@@ -406,6 +451,112 @@ mod tests {
OutputGeometry::new(x, 0, 1_920, 1_080) 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] #[test]
fn new_outputs_receive_distinct_workspaces() { fn new_outputs_receive_distinct_workspaces() {
let mut outputs = OutputWorkspaceMap::default(); let mut outputs = OutputWorkspaceMap::default();
+24 -2
View File
@@ -206,10 +206,30 @@ theme {
A single-output session starts with workspace 1. Numbered workspace shortcuts create A single-output session starts with workspace 1. Numbered workspace shortcuts create
workspaces on demand; switching forward past the last workspace also creates the workspaces on demand; switching forward past the last workspace also creates the
next one when the current workspace contains windows. Empty workspaces are next one when the current workspace contains windows. Empty workspaces are
cleaned up automatically, keeping at most one empty workspace in addition to cleaned up automatically, keeping at most one spare empty workspace in addition to
any empty workspace currently shown on another display. Additional displays visible workspaces and each monitor's previous workspace. Additional displays
receive their own workspace. 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
| `input` key | Type | Default | Meaning | | `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"` | | `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` |
| `workspace`, `move-to-workspace` | Workspace number string, 1–255 | | `workspace`, `move-to-workspace` | Workspace number string, 1–255 |
| `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor | | `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 | | `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 | | `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+Shift+1–9 move to workspace; Super+R width cycle; Super+C center;
Super+[/] consume/expel; Super+F maximize; Super+Shift+F fullscreen; Super+[/] consume/expel; Super+F maximize; Super+Shift+F fullscreen;
Super+M layout; Super+Shift+Space floating; Super+Tab overview; 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+S area screenshot; Print Screen whole-screen screenshot;
Super+Shift+E logout confirmation. Super+Shift+E logout confirmation.
+6
View File
@@ -56,10 +56,16 @@ overview.
| --- | --- | | --- | --- |
| Super + 1–9 | Switch to that workspace | | Super + 1–9 | Switch to that workspace |
| Super + Shift + 1–9 | Move the window 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 Workspaces are created as needed. Selecting one visible on another monitor focuses
that monitor. 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 ## Desktop actions
| Shortcut | Action | | Shortcut | Action |
+24 -4
View File
@@ -141,9 +141,24 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
)); ));
} }
}, },
"toggle-floating" | "toggle-fullscreen" | "toggle-maximized" | "toggle-layout" | "toggle-overview" "toggle-floating"
| "cycle-column-width" | "center-column" | "consume" | "expel" | "close" | "get-focused-window" | "toggle-fullscreen"
| "get-windows" | "get-workspaces" | "get-outputs" | "reload-config" | "exit" | "request-logout" => { | "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() { if !positional.is_empty() {
return Err(format!("{command} does not accept arguments")); return Err(format!("{command} does not accept arguments"));
} }
@@ -172,7 +187,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
} }
fn usage() -> String { fn usage() -> String {
"usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned() "usage: feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
} }
#[cfg(test)] #[cfg(test)]
@@ -202,6 +217,11 @@ mod tests {
parse_args(["cycle-column-width".to_owned()]).unwrap(), parse_args(["cycle-column-width".to_owned()]).unwrap(),
("cycle-column-width".to_owned(), json!({})) ("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] #[test]
+5
View File
@@ -68,6 +68,11 @@ layout {
smart-gaps #false smart-gaps #false
} }
workspaces {
// Press the current workspace's shortcut again to return to the previous one.
auto-back-and-forth #false
}
scrolling { scrolling {
default-column-width 0.5 default-column-width 0.5
focus-strategy "paged" focus-strategy "paged"
@@ -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()
+1 -1
View File
@@ -928,7 +928,7 @@ impl cosmic::Application for App {
); );
} }
Page::Shortcuts => { 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); let mut gestures = row([]).spacing(6);
for (keys, label) in [ for (keys, label) in [
("Swipe3Up", "Customize swipe up"), ("Swipe3Up", "Customize swipe up"),
+6
View File
@@ -824,6 +824,12 @@ pub fn fields(page: Page) -> Vec<Field> {
), ),
], ],
Page::Windows => 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( choice(
"layout.mode", "layout.mode",
"Layout for new workspaces", "Layout for new workspaces",