From 3d8c7b0c4f833916d23f0931838fb34bfb43d132 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 24 Sep 2026 11:08:42 +0100 Subject: [PATCH] feat: integrate numeric workspaces --- compositor/src/handlers/activation.rs | 6 ++ compositor/src/input.rs | 37 +++++++- compositor/src/state.rs | 128 +++++++++++++++++++++++--- crates/ferese-layout/src/workspace.rs | 32 +++++++ 4 files changed, 187 insertions(+), 16 deletions(-) diff --git a/compositor/src/handlers/activation.rs b/compositor/src/handlers/activation.rs index fe3fb28..0d8d2d7 100644 --- a/compositor/src/handlers/activation.rs +++ b/compositor/src/handlers/activation.rs @@ -48,6 +48,12 @@ impl XdgActivationHandler for Ferese { }; self.focused_window = self.window_ids.get(&window).copied(); + if let Some(focused) = self.focused_window + && let Err(error) = self.workspaces.focus_window(focused) + { + tracing::error!(%error, ?focused, "failed to update workspace focus"); + return; + } self.space.raise_element(&window, true); let keyboard = self.seat.get_keyboard().expect("seat has a keyboard"); keyboard.set_focus(self, Some(surface), SERIAL_COUNTER.next_serial()); diff --git a/compositor/src/input.rs b/compositor/src/input.rs index b6b4a64..1bdf67f 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -37,8 +37,9 @@ impl Ferese { let supported_modifiers = modifiers.logo && !modifiers.alt && !(modifiers.ctrl && modifiers.shift); + let symbol = keysym.modified_sym().raw(); let direction = if supported_modifiers { - match keysym.modified_sym().raw() { + match symbol { keysyms::KEY_h | keysyms::KEY_H => Some(Direction::Left), keysyms::KEY_j | keysyms::KEY_J => Some(Direction::Down), keysyms::KEY_k | keysyms::KEY_K => Some(Direction::Up), @@ -63,6 +64,35 @@ impl Ferese { } FilterResult::Intercept(()) + } else if modifiers.logo && !modifiers.ctrl && !modifiers.alt { + let workspace = match symbol { + keysyms::KEY_1 => Some(1), + keysyms::KEY_2 => Some(2), + keysyms::KEY_3 => Some(3), + keysyms::KEY_4 => Some(4), + keysyms::KEY_5 => Some(5), + keysyms::KEY_6 => Some(6), + keysyms::KEY_7 => Some(7), + keysyms::KEY_8 => Some(8), + keysyms::KEY_9 => Some(9), + _ => None, + }; + + if let Some(workspace) = workspace { + if state == KeyState::Pressed { + data.intercepted_keys.insert(keycode); + + if modifiers.shift { + data.move_focused_to_workspace(workspace); + } else { + data.switch_workspace(workspace); + } + } + + FilterResult::Intercept(()) + } else { + FilterResult::Forward + } } else { FilterResult::Forward } @@ -175,6 +205,11 @@ impl Ferese { let window = window.clone(); self.focused_window = self.window_ids.get(&window).copied(); + if let Some(focused) = self.focused_window + && let Err(error) = self.workspaces.focus_window(focused) + { + tracing::error!(%error, ?focused, "failed to update workspace focus"); + } self.space.raise_element(&window, true); let surface = window .toplevel() diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 59cd642..2b173ba 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -6,7 +6,7 @@ use std::{ time::Instant, }; -use ferese_layout::{Axis, Direction, GapConfig, LayoutTree, Rect, WindowId}; +use ferese_layout::{Axis, Direction, GapConfig, Rect, WindowId, WorkspaceSet}; use smithay::{ desktop::{PopupManager, Space, Window, WindowSurfaceType}, @@ -43,7 +43,7 @@ pub struct Ferese { pub display_handle: DisplayHandle, pub loop_signal: LoopSignal, pub space: Space, - pub layout: LayoutTree, + pub workspaces: WorkspaceSet, pub window_ids: HashMap, pub focused_window: Option, pub intercepted_keys: HashSet, @@ -98,7 +98,7 @@ impl Ferese { display_handle, loop_signal: event_loop.get_signal(), space: Space::default(), - layout: LayoutTree::default(), + workspaces: WorkspaceSet::default(), window_ids: HashMap::new(), focused_window: None, intercepted_keys: HashSet::new(), @@ -170,10 +170,16 @@ impl Ferese { let axis = self .output_bounds() - .and_then(|bounds| self.layout.automatic_axis(self.focused_window, bounds).ok()) + .and_then(|bounds| { + self.workspaces + .active() + .layout + .automatic_axis(self.focused_window, bounds) + .ok() + }) .unwrap_or(Axis::Horizontal); - if let Err(error) = self.layout.insert(id, self.focused_window, axis, 0.5) { + if let Err(error) = self.workspaces.insert_window(id, axis, 0.5) { tracing::error!(%error, ?id, "failed to insert window into layout"); return; } @@ -190,15 +196,11 @@ impl Ferese { }; self.space.unmap_elem(window); - if let Err(error) = self.layout.remove(id) { + if let Err(error) = self.workspaces.remove_window(id) { tracing::error!(%error, ?id, "failed to remove window from layout"); } if self.focused_window == Some(id) { - self.focused_window = self - .space - .elements() - .rev() - .find_map(|window| self.window_ids.get(window).copied()); + self.focused_window = self.workspaces.active().last_focused; } self.relayout(); @@ -208,7 +210,24 @@ impl Ferese { let Some(bounds) = self.output_bounds() else { return; }; - let geometry = match self.layout.geometry_with_gaps(bounds, GapConfig::default()) { + let active = self.workspaces.active_id(); + let inactive = self + .window_ids + .iter() + .filter(|(_, id)| self.workspaces.workspace_for_window(**id) != Some(active)) + .map(|(window, _)| window.clone()) + .collect::>(); + + for window in inactive { + self.space.unmap_elem(&window); + } + + let geometry = match self + .workspaces + .active() + .layout + .geometry_with_gaps(bounds, GapConfig::default()) + { Ok(geometry) => geometry, Err(error) => { tracing::error!(%error, "failed to compute tiled geometry"); @@ -243,9 +262,19 @@ impl Ferese { let Some(bounds) = self.output_bounds() else { return; }; - let Ok(Some(next)) = self.layout.directional_neighbor(current, direction, bounds) else { + let Ok(Some(next)) = self + .workspaces + .active() + .layout + .directional_neighbor(current, direction, bounds) + else { return; }; + + if let Err(error) = self.workspaces.focus_window(next) { + tracing::error!(%error, ?next, "failed to update workspace focus"); + return; + } let Some(window) = self .window_ids .iter() @@ -286,7 +315,12 @@ impl Ferese { return; }; - match self.layout.move_window(current, direction, bounds) { + match self + .workspaces + .active_mut() + .layout + .move_window(current, direction, bounds) + { Ok(true) => self.relayout(), Ok(false) => {} Err(error) => tracing::error!(%error, ?current, "failed to move tiled window"), @@ -300,13 +334,77 @@ impl Ferese { return; }; - match self.layout.resize_window(current, direction, RESIZE_STEP) { + match self + .workspaces + .active_mut() + .layout + .resize_window(current, direction, RESIZE_STEP) + { Ok(true) => self.relayout(), Ok(false) => {} Err(error) => tracing::error!(%error, ?current, "failed to resize tiled window"), } } + pub fn switch_workspace(&mut self, index: u32) { + let focus = match self.workspaces.switch_to_numeric(index) { + Ok(focus) => focus, + Err(error) => { + tracing::error!(%error, index, "failed to switch workspace"); + return; + } + }; + + self.focused_window = focus; + self.relayout(); + self.restore_keyboard_focus(); + } + + pub fn move_focused_to_workspace(&mut self, index: u32) { + let Some(window) = self.focused_window else { + return; + }; + let axis = self + .output_bounds() + .and_then(|bounds| { + let workspace = self.workspaces.ensure_numeric(index).ok()?; + let target = self.workspaces.workspace(workspace)?; + target + .layout + .automatic_axis(target.last_focused, bounds) + .ok() + }) + .unwrap_or(Axis::Horizontal); + + if let Err(error) = self + .workspaces + .move_window_to_numeric(window, index, axis, 0.5) + { + tracing::error!(%error, ?window, index, "failed to move window to workspace"); + return; + } + + self.focused_window = self.workspaces.active().last_focused; + self.relayout(); + self.restore_keyboard_focus(); + } + + fn restore_keyboard_focus(&mut self) { + let surface = self.focused_window.and_then(|focused| { + self.window_ids.iter().find_map(|(window, id)| { + (*id == focused) + .then(|| window.toplevel()) + .flatten() + .map(|toplevel| toplevel.wl_surface().clone()) + }) + }); + + self.seat + .get_keyboard() + .expect("seat has a keyboard") + .set_focus(self, surface, smithay::utils::SERIAL_COUNTER.next_serial()); + } + fn output_bounds(&self) -> Option { let output = self.space.outputs().next()?; let geometry = self.space.output_geometry(output)?; diff --git a/crates/ferese-layout/src/workspace.rs b/crates/ferese-layout/src/workspace.rs index cc9ed3b..5f5732e 100644 --- a/crates/ferese-layout/src/workspace.rs +++ b/crates/ferese-layout/src/workspace.rs @@ -111,6 +111,19 @@ impl WorkspaceSet { self.window_workspaces.get(&window).copied() } + pub fn focus_window(&mut self, window: WindowId) -> Result<(), WorkspaceError> { + if self.workspace_for_window(window) != Some(self.active) { + return Err(WorkspaceError::InvalidState( + "focused window is not on the active workspace", + )); + } + + self.active_mut().last_focused = Some(window); + + debug_assert!(self.validate().is_ok()); + Ok(()) + } + pub fn ensure_numeric(&mut self, index: u32) -> Result { if index == 0 { return Err(WorkspaceError::InvalidNumericName(index)); @@ -360,4 +373,23 @@ mod tests { assert_eq!(workspaces.active().last_focused, Some(WindowId(2))); assert!(workspaces.validate().is_ok()); } + + #[test] + fn focus_rejects_a_window_on_an_inactive_workspace() { + let mut workspaces = WorkspaceSet::default(); + workspaces + .insert_window(WindowId(1), Axis::Horizontal, 0.5) + .unwrap(); + workspaces + .move_window_to_numeric(WindowId(1), 2, Axis::Horizontal, 0.5) + .unwrap(); + + assert_eq!( + workspaces.focus_window(WindowId(1)), + Err(WorkspaceError::InvalidState( + "focused window is not on the active workspace" + )) + ); + assert!(workspaces.validate().is_ok()); + } }