Sprint 3: Multiple Workspaces
Goal: Implement named workspaces with switching and window movement.
Objectives
- Multiple independent workspaces, each with its own BSP tree
- Keyboard-based workspace switching
- Move windows between workspaces
- EWMH workspace properties for status bar integration
Prerequisites
- Sprint 2 complete (smart split, keyboard navigation)
Tasks
3.1 Workspace Data Structure
- Create
src/core/workspace.rs - Define
Workspacestruct - Create
WorkspaceManagerto hold all workspaces - Initialize default workspaces (1-10)
pub struct Workspace {
pub id: WorkspaceId,
pub name: String,
pub tree: Node,
pub floating: Vec<Window>,
pub focused: Option<WindowId>,
pub visible: bool,
}
pub struct WorkspaceManager {
workspaces: Vec<Workspace>,
active: WorkspaceId,
}
3.2 Workspace Initialization
- Create 10 default workspaces (named "1" through "10")
- Set workspace 1 as active on startup
- Existing windows go to workspace 1
impl WorkspaceManager {
pub fn new() -> Self {
let workspaces = (1..=10)
.map(|i| Workspace {
id: WorkspaceId(i),
name: i.to_string(),
tree: Node::empty(),
floating: Vec::new(),
focused: None,
visible: i == 1,
})
.collect();
Self { workspaces, active: WorkspaceId(1) }
}
}
3.3 Workspace Switching
- Implement Mod+1 through Mod+9 (and Mod+0 for 10)
- Hide windows on old workspace
- Show windows on new workspace
- Restore focus to previously focused window
- Update EWMH _NET_CURRENT_DESKTOP
fn switch_workspace(&mut self, conn: &impl Connection, target: WorkspaceId) -> Result<()> {
if target == self.active {
return Ok(());
}
// Hide current workspace windows
for window in self.current_workspace().all_windows() {
conn.unmap_window(window)?;
}
// Show target workspace windows
self.active = target;
for window in self.current_workspace().all_windows() {
conn.map_window(window)?;
}
// Restore focus
if let Some(focused) = self.current_workspace().focused {
set_focus(conn, focused)?;
}
Ok(())
}
3.4 Move Window to Workspace
- Implement Mod+Shift+1 through Mod+Shift+9
- Remove window from current workspace tree
- Add window to target workspace tree
- If target workspace is not visible, unmap window
- Optionally follow window to new workspace (configurable later)
fn move_to_workspace(
&mut self,
conn: &impl Connection,
window: WindowId,
target: WorkspaceId,
) -> Result<()> {
// Remove from current
self.current_workspace_mut().tree.remove(window);
// Add to target
self.workspace_mut(target).tree.insert(window);
// Hide if target not visible
if target != self.active {
conn.unmap_window(window)?;
}
// Retile both workspaces
self.apply_layout(conn, self.active)?;
self.apply_layout(conn, target)?;
Ok(())
}
3.5 Per-Workspace Trees
- Modify window insertion to use active workspace
- Modify window removal to find correct workspace
- Ensure tree operations target correct workspace
- Handle window destroy when on non-active workspace
3.6 EWMH Workspace Support
- Set
_NET_NUMBER_OF_DESKTOPSon root - Set
_NET_DESKTOP_NAMESwith workspace names - Set
_NET_CURRENT_DESKTOPwhen switching - Set
_NET_WM_DESKTOPon each window - Handle
_NET_CURRENT_DESKTOPclient messages (pagers)
fn set_ewmh_workspace_hints(conn: &impl Connection, manager: &WorkspaceManager) -> Result<()> {
let root = get_root(conn);
// Number of desktops
conn.change_property32(
PropMode::REPLACE,
root,
atoms._NET_NUMBER_OF_DESKTOPS,
AtomEnum::CARDINAL,
&[manager.workspaces.len() as u32],
)?;
// Desktop names
let names: Vec<u8> = manager.workspaces
.iter()
.flat_map(|ws| ws.name.bytes().chain(std::iter::once(0)))
.collect();
conn.change_property8(
PropMode::REPLACE,
root,
atoms._NET_DESKTOP_NAMES,
atoms.UTF8_STRING,
&names,
)?;
// Current desktop
conn.change_property32(
PropMode::REPLACE,
root,
atoms._NET_CURRENT_DESKTOP,
AtomEnum::CARDINAL,
&[manager.active.0 as u32 - 1], // 0-indexed for EWMH
)?;
Ok(())
}
3.7 Handle Existing Windows on Startup
- Query existing windows on WM start
- Check
_NET_WM_DESKTOPfor previous workspace assignment - Place windows in appropriate workspaces
- Map windows in active workspace only
Keybind Summary
| Keybind | Action |
|---|---|
| Mod+1-9 | Switch to workspace 1-9 |
| Mod+0 | Switch to workspace 10 |
| Mod+Shift+1-9 | Move window to workspace 1-9 |
| Mod+Shift+0 | Move window to workspace 10 |
Acceptance Criteria
- Can switch between 10 workspaces
- Each workspace maintains its own window layout
- Moving window to another workspace works
- Status bars (polybar) can display workspace info
- Windows hidden/shown correctly on switch
- Focus restored correctly when switching back
Testing Strategy
# Test workspace switching
# Mod+1 - should be on workspace 1
# Open xterm
# Mod+2 - switch to workspace 2 (empty)
# Open xterm - should be alone
# Mod+1 - back to workspace 1, original xterm visible
# Test window movement
# On workspace 1 with xterm
# Mod+Shift+2 - moves xterm to workspace 2
# Mod+2 - verify xterm is there
# Test polybar integration
polybar example # Should show workspace indicators
Status Bar Integration
For polybar, create an i3-compatible module (we'll implement IPC in Sprint 6):
[module/workspaces]
type = internal/xworkspaces
label-active = %name%
label-occupied = %name%
label-empty = %name%
Notes
- 0-indexed for EWMH, 1-indexed for user display
- Consider urgent workspace highlighting (Sprint 8)
- Named workspaces beyond 1-10 come with Lua config (Sprint 4)
- Multi-monitor workspace assignment comes in Sprint 7
View source
| 1 | # Sprint 3: Multiple Workspaces |
| 2 | |
| 3 | **Goal:** Implement named workspaces with switching and window movement. |
| 4 | |
| 5 | ## Objectives |
| 6 | |
| 7 | - Multiple independent workspaces, each with its own BSP tree |
| 8 | - Keyboard-based workspace switching |
| 9 | - Move windows between workspaces |
| 10 | - EWMH workspace properties for status bar integration |
| 11 | |
| 12 | ## Prerequisites |
| 13 | |
| 14 | - Sprint 2 complete (smart split, keyboard navigation) |
| 15 | |
| 16 | ## Tasks |
| 17 | |
| 18 | ### 3.1 Workspace Data Structure |
| 19 | - [ ] Create `src/core/workspace.rs` |
| 20 | - [ ] Define `Workspace` struct |
| 21 | - [ ] Create `WorkspaceManager` to hold all workspaces |
| 22 | - [ ] Initialize default workspaces (1-10) |
| 23 | |
| 24 | ```rust |
| 25 | pub struct Workspace { |
| 26 | pub id: WorkspaceId, |
| 27 | pub name: String, |
| 28 | pub tree: Node, |
| 29 | pub floating: Vec<Window>, |
| 30 | pub focused: Option<WindowId>, |
| 31 | pub visible: bool, |
| 32 | } |
| 33 | |
| 34 | pub struct WorkspaceManager { |
| 35 | workspaces: Vec<Workspace>, |
| 36 | active: WorkspaceId, |
| 37 | } |
| 38 | ``` |
| 39 | |
| 40 | ### 3.2 Workspace Initialization |
| 41 | - [ ] Create 10 default workspaces (named "1" through "10") |
| 42 | - [ ] Set workspace 1 as active on startup |
| 43 | - [ ] Existing windows go to workspace 1 |
| 44 | |
| 45 | ```rust |
| 46 | impl WorkspaceManager { |
| 47 | pub fn new() -> Self { |
| 48 | let workspaces = (1..=10) |
| 49 | .map(|i| Workspace { |
| 50 | id: WorkspaceId(i), |
| 51 | name: i.to_string(), |
| 52 | tree: Node::empty(), |
| 53 | floating: Vec::new(), |
| 54 | focused: None, |
| 55 | visible: i == 1, |
| 56 | }) |
| 57 | .collect(); |
| 58 | Self { workspaces, active: WorkspaceId(1) } |
| 59 | } |
| 60 | } |
| 61 | ``` |
| 62 | |
| 63 | ### 3.3 Workspace Switching |
| 64 | - [ ] Implement Mod+1 through Mod+9 (and Mod+0 for 10) |
| 65 | - [ ] Hide windows on old workspace |
| 66 | - [ ] Show windows on new workspace |
| 67 | - [ ] Restore focus to previously focused window |
| 68 | - [ ] Update EWMH _NET_CURRENT_DESKTOP |
| 69 | |
| 70 | ```rust |
| 71 | fn switch_workspace(&mut self, conn: &impl Connection, target: WorkspaceId) -> Result<()> { |
| 72 | if target == self.active { |
| 73 | return Ok(()); |
| 74 | } |
| 75 | |
| 76 | // Hide current workspace windows |
| 77 | for window in self.current_workspace().all_windows() { |
| 78 | conn.unmap_window(window)?; |
| 79 | } |
| 80 | |
| 81 | // Show target workspace windows |
| 82 | self.active = target; |
| 83 | for window in self.current_workspace().all_windows() { |
| 84 | conn.map_window(window)?; |
| 85 | } |
| 86 | |
| 87 | // Restore focus |
| 88 | if let Some(focused) = self.current_workspace().focused { |
| 89 | set_focus(conn, focused)?; |
| 90 | } |
| 91 | |
| 92 | Ok(()) |
| 93 | } |
| 94 | ``` |
| 95 | |
| 96 | ### 3.4 Move Window to Workspace |
| 97 | - [ ] Implement Mod+Shift+1 through Mod+Shift+9 |
| 98 | - [ ] Remove window from current workspace tree |
| 99 | - [ ] Add window to target workspace tree |
| 100 | - [ ] If target workspace is not visible, unmap window |
| 101 | - [ ] Optionally follow window to new workspace (configurable later) |
| 102 | |
| 103 | ```rust |
| 104 | fn move_to_workspace( |
| 105 | &mut self, |
| 106 | conn: &impl Connection, |
| 107 | window: WindowId, |
| 108 | target: WorkspaceId, |
| 109 | ) -> Result<()> { |
| 110 | // Remove from current |
| 111 | self.current_workspace_mut().tree.remove(window); |
| 112 | |
| 113 | // Add to target |
| 114 | self.workspace_mut(target).tree.insert(window); |
| 115 | |
| 116 | // Hide if target not visible |
| 117 | if target != self.active { |
| 118 | conn.unmap_window(window)?; |
| 119 | } |
| 120 | |
| 121 | // Retile both workspaces |
| 122 | self.apply_layout(conn, self.active)?; |
| 123 | self.apply_layout(conn, target)?; |
| 124 | |
| 125 | Ok(()) |
| 126 | } |
| 127 | ``` |
| 128 | |
| 129 | ### 3.5 Per-Workspace Trees |
| 130 | - [ ] Modify window insertion to use active workspace |
| 131 | - [ ] Modify window removal to find correct workspace |
| 132 | - [ ] Ensure tree operations target correct workspace |
| 133 | - [ ] Handle window destroy when on non-active workspace |
| 134 | |
| 135 | ### 3.6 EWMH Workspace Support |
| 136 | - [ ] Set `_NET_NUMBER_OF_DESKTOPS` on root |
| 137 | - [ ] Set `_NET_DESKTOP_NAMES` with workspace names |
| 138 | - [ ] Set `_NET_CURRENT_DESKTOP` when switching |
| 139 | - [ ] Set `_NET_WM_DESKTOP` on each window |
| 140 | - [ ] Handle `_NET_CURRENT_DESKTOP` client messages (pagers) |
| 141 | |
| 142 | ```rust |
| 143 | fn set_ewmh_workspace_hints(conn: &impl Connection, manager: &WorkspaceManager) -> Result<()> { |
| 144 | let root = get_root(conn); |
| 145 | |
| 146 | // Number of desktops |
| 147 | conn.change_property32( |
| 148 | PropMode::REPLACE, |
| 149 | root, |
| 150 | atoms._NET_NUMBER_OF_DESKTOPS, |
| 151 | AtomEnum::CARDINAL, |
| 152 | &[manager.workspaces.len() as u32], |
| 153 | )?; |
| 154 | |
| 155 | // Desktop names |
| 156 | let names: Vec<u8> = manager.workspaces |
| 157 | .iter() |
| 158 | .flat_map(|ws| ws.name.bytes().chain(std::iter::once(0))) |
| 159 | .collect(); |
| 160 | conn.change_property8( |
| 161 | PropMode::REPLACE, |
| 162 | root, |
| 163 | atoms._NET_DESKTOP_NAMES, |
| 164 | atoms.UTF8_STRING, |
| 165 | &names, |
| 166 | )?; |
| 167 | |
| 168 | // Current desktop |
| 169 | conn.change_property32( |
| 170 | PropMode::REPLACE, |
| 171 | root, |
| 172 | atoms._NET_CURRENT_DESKTOP, |
| 173 | AtomEnum::CARDINAL, |
| 174 | &[manager.active.0 as u32 - 1], // 0-indexed for EWMH |
| 175 | )?; |
| 176 | |
| 177 | Ok(()) |
| 178 | } |
| 179 | ``` |
| 180 | |
| 181 | ### 3.7 Handle Existing Windows on Startup |
| 182 | - [ ] Query existing windows on WM start |
| 183 | - [ ] Check `_NET_WM_DESKTOP` for previous workspace assignment |
| 184 | - [ ] Place windows in appropriate workspaces |
| 185 | - [ ] Map windows in active workspace only |
| 186 | |
| 187 | ## Keybind Summary |
| 188 | |
| 189 | | Keybind | Action | |
| 190 | |---------|--------| |
| 191 | | Mod+1-9 | Switch to workspace 1-9 | |
| 192 | | Mod+0 | Switch to workspace 10 | |
| 193 | | Mod+Shift+1-9 | Move window to workspace 1-9 | |
| 194 | | Mod+Shift+0 | Move window to workspace 10 | |
| 195 | |
| 196 | ## Acceptance Criteria |
| 197 | |
| 198 | 1. Can switch between 10 workspaces |
| 199 | 2. Each workspace maintains its own window layout |
| 200 | 3. Moving window to another workspace works |
| 201 | 4. Status bars (polybar) can display workspace info |
| 202 | 5. Windows hidden/shown correctly on switch |
| 203 | 6. Focus restored correctly when switching back |
| 204 | |
| 205 | ## Testing Strategy |
| 206 | |
| 207 | ```bash |
| 208 | # Test workspace switching |
| 209 | # Mod+1 - should be on workspace 1 |
| 210 | # Open xterm |
| 211 | # Mod+2 - switch to workspace 2 (empty) |
| 212 | # Open xterm - should be alone |
| 213 | # Mod+1 - back to workspace 1, original xterm visible |
| 214 | |
| 215 | # Test window movement |
| 216 | # On workspace 1 with xterm |
| 217 | # Mod+Shift+2 - moves xterm to workspace 2 |
| 218 | # Mod+2 - verify xterm is there |
| 219 | |
| 220 | # Test polybar integration |
| 221 | polybar example # Should show workspace indicators |
| 222 | ``` |
| 223 | |
| 224 | ## Status Bar Integration |
| 225 | |
| 226 | For polybar, create an i3-compatible module (we'll implement IPC in Sprint 6): |
| 227 | |
| 228 | ```ini |
| 229 | [module/workspaces] |
| 230 | type = internal/xworkspaces |
| 231 | label-active = %name% |
| 232 | label-occupied = %name% |
| 233 | label-empty = %name% |
| 234 | ``` |
| 235 | |
| 236 | ## Notes |
| 237 | |
| 238 | - 0-indexed for EWMH, 1-indexed for user display |
| 239 | - Consider urgent workspace highlighting (Sprint 8) |
| 240 | - Named workspaces beyond 1-10 come with Lua config (Sprint 4) |
| 241 | - Multi-monitor workspace assignment comes in Sprint 7 |