Add per-monitor workspace back-and-forth
This commit is contained in:
1 parent
cb6fe73e44
commit
17b8482433
12 files changed
+405
-20
No files matched your search
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user