From 462179b94d9808caf259b9ac9fa51b781de51572 Mon Sep 17 00:00:00 2001 From: Louis047 Date: Sun, 22 Feb 2026 23:09:23 +0530 Subject: [PATCH] feat: add populated workspace navigation commands Add focus --next-populated-workspace / --prev-populated-workspace and move --next-populated-workspace / --prev-populated-workspace commands. These navigate only through workspaces that contain windows, skipping empty ones. Wrap-around is supported. When the origin workspace is empty, the nearest populated workspace in the given direction is selected. Closes: jack-work/scoop-lavawm#1 --- packages/wm-common/src/app_command.rs | 12 +++ packages/wm/src/models/workspace_target.rs | 2 + packages/wm/src/wm.rs | 36 +++++++- packages/wm/src/wm_state.rs | 96 ++++++++++++++++++++++ 4 files changed, 145 insertions(+), 1 deletion(-) diff --git a/packages/wm-common/src/app_command.rs b/packages/wm-common/src/app_command.rs index 11c1e06f8..23f2e8c46 100644 --- a/packages/wm-common/src/app_command.rs +++ b/packages/wm-common/src/app_command.rs @@ -361,6 +361,12 @@ pub struct InvokeFocusCommand { #[clap(long)] pub recent_workspace: bool, + + #[clap(long)] + pub next_populated_workspace: bool, + + #[clap(long)] + pub prev_populated_workspace: bool, } #[derive(Args, Clone, Debug, PartialEq, Serialize)] @@ -399,6 +405,12 @@ pub struct InvokeMoveCommand { #[clap(long)] pub recent_workspace: bool, + + #[clap(long)] + pub next_populated_workspace: bool, + + #[clap(long)] + pub prev_populated_workspace: bool, } #[derive(Args, Clone, Debug, PartialEq, Serialize)] diff --git a/packages/wm/src/models/workspace_target.rs b/packages/wm/src/models/workspace_target.rs index dbfd62b1c..3195ffa89 100644 --- a/packages/wm/src/models/workspace_target.rs +++ b/packages/wm/src/models/workspace_target.rs @@ -9,6 +9,8 @@ pub enum WorkspaceTarget { PreviousActiveInMonitor, Next, Previous, + NextPopulated, + PreviousPopulated, #[allow(dead_code)] Direction(Direction), } diff --git a/packages/wm/src/wm.rs b/packages/wm/src/wm.rs index 1e64ee550..2c0aa1fa9 100644 --- a/packages/wm/src/wm.rs +++ b/packages/wm/src/wm.rs @@ -317,6 +317,22 @@ impl WindowManager { )?; } + if args.next_populated_workspace { + focus_workspace( + WorkspaceTarget::NextPopulated, + state, + config, + )?; + } + + if args.prev_populated_workspace { + focus_workspace( + WorkspaceTarget::PreviousPopulated, + state, + config, + )?; + } + Ok(()) } InvokeCommand::Ignore => { @@ -411,12 +427,30 @@ impl WindowManager { if args.prev_active_workspace_on_monitor { move_window_to_workspace( - window, + window.clone(), WorkspaceTarget::PreviousActiveInMonitor, state, config, )?; } + + if args.next_populated_workspace { + move_window_to_workspace( + window.clone(), + WorkspaceTarget::NextPopulated, + state, + config, + )?; + } + + if args.prev_populated_workspace { + move_window_to_workspace( + window, + WorkspaceTarget::PreviousPopulated, + state, + config, + )?; + } Ok(()) } diff --git a/packages/wm/src/wm_state.rs b/packages/wm/src/wm_state.rs index 4ade7b011..b928f6bb9 100644 --- a/packages/wm/src/wm_state.rs +++ b/packages/wm/src/wm_state.rs @@ -472,6 +472,102 @@ impl WmState { (previous_workspace_name, previous_workspace) } + WorkspaceTarget::NextPopulated => { + let all_sorted = self.sorted_workspaces(config); + let populated: Vec<_> = all_sorted + .iter() + .filter(|w| w.has_children()) + .cloned() + .collect(); + + let next = if populated.is_empty() { + None + } else { + match populated + .iter() + .position(|w| w.id() == origin_workspace.id()) + { + Some(i) => populated + .get(i + 1) + .or_else(|| populated.first()) + .cloned(), + None => { + // Origin is empty; find its position in the full + // sorted list and pick the next populated workspace. + let origin_pos = all_sorted + .iter() + .position(|w| w.id() == origin_workspace.id()) + .unwrap_or(0); + populated + .iter() + .find(|w| { + all_sorted + .iter() + .position(|s| s.id() == w.id()) + .unwrap_or(0) + > origin_pos + }) + .or_else(|| populated.first()) + .cloned() + } + } + }; + + ( + next.as_ref().map(|w| w.config().name), + next, + ) + } + WorkspaceTarget::PreviousPopulated => { + let all_sorted = self.sorted_workspaces(config); + let populated: Vec<_> = all_sorted + .iter() + .filter(|w| w.has_children()) + .cloned() + .collect(); + + let prev = if populated.is_empty() { + None + } else { + match populated + .iter() + .position(|w| w.id() == origin_workspace.id()) + { + Some(i) => populated + .get( + i.checked_sub(1) + .unwrap_or(populated.len() - 1), + ) + .cloned(), + None => { + // Origin is empty; find its position in the full + // sorted list and pick the previous populated workspace. + let origin_pos = all_sorted + .iter() + .position(|w| w.id() == origin_workspace.id()) + .unwrap_or(0); + populated + .iter() + .rev() + .find(|w| { + all_sorted + .iter() + .position(|s| s.id() == w.id()) + .unwrap_or(0) + < origin_pos + }) + .or_else(|| populated.last()) + .cloned() + } + } + }; + + ( + prev.as_ref().map(|w| w.config().name), + prev, + ) + } + WorkspaceTarget::Direction(direction) => { let origin_monitor = origin_workspace.monitor().context("No focused monitor.")?;