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)]
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<LayoutModeValue>,
@@ -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<BindingConfig> {
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<PathBuf> {
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));
}
+2 -1
View File
@@ -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));
+1
View File
@@ -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(),
+36 -5
View File
@@ -276,6 +276,7 @@ pub struct Ferese {
gap_config: GapConfig,
pub(crate) input_settings: InputSettings,
pub(crate) bindings: Vec<Binding>,
workspace_auto_back_and_forth: bool,
window_rules: Vec<WindowRule>,
window_rules_applied: HashSet<WindowId>,
pub(crate) theme_settings: ThemeSettings,
@@ -354,6 +355,7 @@ pub struct RuntimeConfig {
pub gap_config: GapConfig,
pub input_settings: InputSettings,
pub bindings: Vec<Binding>,
pub workspace_auto_back_and_forth: bool,
pub window_rules: Vec<WindowRule>,
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<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.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<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) {
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);
+155 -4
View File
@@ -59,9 +59,19 @@ pub enum WorkspaceSwitch {
struct OutputState {
geometry: OutputGeometry,
active: WorkspaceId,
previous: Option<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)]
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<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> {
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<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> {
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();
+24 -2
View File
@@ -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.
+6
View File
@@ -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 |
+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"
| "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<PathBuf, io::Error> {
}
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)]
@@ -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]
+5
View File
@@ -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"
@@ -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 => {
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"),
+6
View File
@@ -824,6 +824,12 @@ pub fn fields(page: Page) -> Vec<Field> {
),
],
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",