| 1 |
# Sprint 7: Multi-Monitor Support |
| 2 |
|
| 3 |
**Goal:** Proper multi-head support with per-monitor workspaces. |
| 4 |
|
| 5 |
## Objectives |
| 6 |
|
| 7 |
- Detect monitors via RandR extension |
| 8 |
- Assign workspaces to monitors |
| 9 |
- Navigate and move windows between monitors |
| 10 |
- Handle monitor hotplug (connect/disconnect) |
| 11 |
|
| 12 |
## Prerequisites |
| 13 |
|
| 14 |
- Sprint 6 complete (IPC system) |
| 15 |
|
| 16 |
## Monitor Model |
| 17 |
|
| 18 |
``` |
| 19 |
┌─────────────────────────────────────────────────────────────────────┐ |
| 20 |
│ WorkspaceManager │ |
| 21 |
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ |
| 22 |
│ │ Monitor 0 │ │ Monitor 1 │ │ Monitor 2 │ │ |
| 23 |
│ │ "eDP-1" │ │ "HDMI-1" │ │ "DP-1" │ │ |
| 24 |
│ │ │ │ │ │ │ │ |
| 25 |
│ │ WS: 1,2,3 │ │ WS: 4,5,6 │ │ WS: 7,8,9 │ │ |
| 26 |
│ │ Active: 1 │ │ Active: 4 │ │ Active: 7 │ │ |
| 27 |
│ └──────────────┘ └──────────────┘ └──────────────┘ │ |
| 28 |
└─────────────────────────────────────────────────────────────────────┘ |
| 29 |
``` |
| 30 |
|
| 31 |
## Tasks |
| 32 |
|
| 33 |
### 7.1 Monitor Detection |
| 34 |
- [ ] Create `src/core/monitor.rs` |
| 35 |
- [ ] Query RandR for connected outputs |
| 36 |
- [ ] Get monitor geometry (position, size) |
| 37 |
- [ ] Identify primary monitor |
| 38 |
- [ ] Handle disabled/mirrored outputs |
| 39 |
|
| 40 |
```rust |
| 41 |
use x11rb::protocol::randr; |
| 42 |
|
| 43 |
pub struct Monitor { |
| 44 |
pub name: String, |
| 45 |
pub output: randr::Output, |
| 46 |
pub geometry: Rect, |
| 47 |
pub primary: bool, |
| 48 |
pub workspaces: Vec<WorkspaceId>, |
| 49 |
pub active_workspace: WorkspaceId, |
| 50 |
} |
| 51 |
|
| 52 |
fn detect_monitors(conn: &impl Connection, root: Window) -> Result<Vec<Monitor>> { |
| 53 |
let resources = conn.randr_get_screen_resources(root)?.reply()?; |
| 54 |
let mut monitors = Vec::new(); |
| 55 |
|
| 56 |
for output in resources.outputs { |
| 57 |
let info = conn.randr_get_output_info(output, 0)?.reply()?; |
| 58 |
|
| 59 |
if info.connection != randr::Connection::CONNECTED { |
| 60 |
continue; |
| 61 |
} |
| 62 |
|
| 63 |
if let Some(crtc) = info.crtc { |
| 64 |
let crtc_info = conn.randr_get_crtc_info(crtc, 0)?.reply()?; |
| 65 |
monitors.push(Monitor { |
| 66 |
name: String::from_utf8_lossy(&info.name).to_string(), |
| 67 |
output, |
| 68 |
geometry: Rect { |
| 69 |
x: crtc_info.x, |
| 70 |
y: crtc_info.y, |
| 71 |
width: crtc_info.width, |
| 72 |
height: crtc_info.height, |
| 73 |
}, |
| 74 |
primary: false, // Set later |
| 75 |
workspaces: Vec::new(), |
| 76 |
active_workspace: WorkspaceId(0), |
| 77 |
}); |
| 78 |
} |
| 79 |
} |
| 80 |
|
| 81 |
Ok(monitors) |
| 82 |
} |
| 83 |
``` |
| 84 |
|
| 85 |
### 7.2 Workspace-Monitor Assignment |
| 86 |
- [ ] Each monitor has a set of workspaces |
| 87 |
- [ ] Default: distribute workspaces evenly |
| 88 |
- [ ] Lua API: `gar.assign_workspace(ws, monitor)` |
| 89 |
- [ ] Workspace shows on its assigned monitor |
| 90 |
|
| 91 |
```rust |
| 92 |
impl WorkspaceManager { |
| 93 |
fn assign_workspaces_to_monitors(&mut self) { |
| 94 |
let ws_per_monitor = self.workspaces.len() / self.monitors.len(); |
| 95 |
|
| 96 |
for (i, monitor) in self.monitors.iter_mut().enumerate() { |
| 97 |
let start = i * ws_per_monitor + 1; |
| 98 |
let end = if i == self.monitors.len() - 1 { |
| 99 |
self.workspaces.len() |
| 100 |
} else { |
| 101 |
start + ws_per_monitor |
| 102 |
}; |
| 103 |
|
| 104 |
monitor.workspaces = (start..=end).map(WorkspaceId).collect(); |
| 105 |
monitor.active_workspace = WorkspaceId(start); |
| 106 |
} |
| 107 |
} |
| 108 |
} |
| 109 |
``` |
| 110 |
|
| 111 |
### 7.3 Focus Across Monitors |
| 112 |
- [ ] Implement Mod+comma/period to focus monitor |
| 113 |
- [ ] Track focused monitor |
| 114 |
- [ ] Focus follows workspace on switch |
| 115 |
- [ ] Handle case when monitor has no windows |
| 116 |
|
| 117 |
```rust |
| 118 |
fn focus_monitor(&mut self, direction: Direction) { |
| 119 |
let current_monitor = self.focused_monitor(); |
| 120 |
let target = self.monitor_in_direction(current_monitor, direction)?; |
| 121 |
|
| 122 |
// Focus the active workspace on target monitor |
| 123 |
let workspace = self.monitors[target].active_workspace; |
| 124 |
if let Some(window) = self.workspaces[workspace].focused { |
| 125 |
self.set_focus(window); |
| 126 |
} |
| 127 |
|
| 128 |
self.focused_monitor = target; |
| 129 |
} |
| 130 |
``` |
| 131 |
|
| 132 |
### 7.4 Move Window to Monitor |
| 133 |
- [ ] Implement Mod+Shift+comma/period |
| 134 |
- [ ] Remove from current workspace |
| 135 |
- [ ] Add to target monitor's active workspace |
| 136 |
- [ ] Optionally follow window |
| 137 |
|
| 138 |
```rust |
| 139 |
fn move_to_monitor(&mut self, direction: Direction) { |
| 140 |
let window = self.focused_window()?; |
| 141 |
let target_monitor = self.monitor_in_direction(self.focused_monitor, direction)?; |
| 142 |
let target_workspace = self.monitors[target_monitor].active_workspace; |
| 143 |
|
| 144 |
// Move window |
| 145 |
self.current_workspace_mut().tree.remove(window); |
| 146 |
self.workspace_mut(target_workspace).tree.insert(window); |
| 147 |
|
| 148 |
// Update geometries |
| 149 |
self.apply_layouts()?; |
| 150 |
} |
| 151 |
``` |
| 152 |
|
| 153 |
### 7.5 Per-Monitor Workspace Switching |
| 154 |
- [ ] Mod+N switches workspace on focused monitor |
| 155 |
- [ ] Only switches within monitor's workspace set |
| 156 |
- [ ] Or switches to workspace and focuses its monitor |
| 157 |
- [ ] Configurable behavior via Lua |
| 158 |
|
| 159 |
```rust |
| 160 |
fn switch_workspace(&mut self, workspace: WorkspaceId) { |
| 161 |
// Find which monitor this workspace belongs to |
| 162 |
let target_monitor = self.monitors.iter() |
| 163 |
.position(|m| m.workspaces.contains(&workspace)); |
| 164 |
|
| 165 |
match target_monitor { |
| 166 |
Some(monitor_idx) => { |
| 167 |
// Switch workspace on that monitor |
| 168 |
self.monitors[monitor_idx].active_workspace = workspace; |
| 169 |
|
| 170 |
// Focus that monitor |
| 171 |
self.focused_monitor = monitor_idx; |
| 172 |
|
| 173 |
// Update visibility |
| 174 |
self.update_workspace_visibility(); |
| 175 |
} |
| 176 |
None => { |
| 177 |
tracing::warn!("Workspace {:?} not assigned to any monitor", workspace); |
| 178 |
} |
| 179 |
} |
| 180 |
} |
| 181 |
``` |
| 182 |
|
| 183 |
### 7.6 Monitor Hotplug |
| 184 |
- [ ] Subscribe to RandR events |
| 185 |
- [ ] Handle ScreenChangeNotify |
| 186 |
- [ ] Detect added/removed monitors |
| 187 |
- [ ] Reassign workspaces as needed |
| 188 |
- [ ] Move windows from disconnected monitors |
| 189 |
|
| 190 |
```rust |
| 191 |
fn handle_randr_event(&mut self, event: randr::ScreenChangeNotifyEvent) { |
| 192 |
let new_monitors = detect_monitors(&self.conn, self.root)?; |
| 193 |
|
| 194 |
// Find removed monitors |
| 195 |
for old in &self.monitors { |
| 196 |
if !new_monitors.iter().any(|m| m.name == old.name) { |
| 197 |
// Monitor disconnected - move its workspaces/windows |
| 198 |
self.handle_monitor_removed(old); |
| 199 |
} |
| 200 |
} |
| 201 |
|
| 202 |
// Find added monitors |
| 203 |
for new in &new_monitors { |
| 204 |
if !self.monitors.iter().any(|m| m.name == new.name) { |
| 205 |
// Monitor connected - assign workspaces |
| 206 |
self.handle_monitor_added(new); |
| 207 |
} |
| 208 |
} |
| 209 |
|
| 210 |
self.monitors = new_monitors; |
| 211 |
self.apply_all_layouts()?; |
| 212 |
} |
| 213 |
``` |
| 214 |
|
| 215 |
### 7.7 EWMH Multi-Monitor |
| 216 |
- [ ] Set `_NET_DESKTOP_GEOMETRY` (combined size) |
| 217 |
- [ ] Set `_NET_DESKTOP_VIEWPORT` per workspace |
| 218 |
- [ ] Set `_NET_WORKAREA` per monitor |
| 219 |
- [ ] Update on monitor changes |
| 220 |
|
| 221 |
### 7.8 Lua Configuration |
| 222 |
- [ ] `gar.assign_workspace(ws, monitor_name)` |
| 223 |
- [ ] `gar.focus_monitor(direction)` action |
| 224 |
- [ ] `gar.move_to_monitor(direction)` action |
| 225 |
- [ ] Configure focus-follows-workspace behavior |
| 226 |
|
| 227 |
```lua |
| 228 |
-- Assign specific workspaces to monitors |
| 229 |
gar.assign_workspace(1, "eDP-1") |
| 230 |
gar.assign_workspace(2, "eDP-1") |
| 231 |
gar.assign_workspace(3, "HDMI-1") |
| 232 |
gar.assign_workspace(4, "HDMI-1") |
| 233 |
|
| 234 |
-- Keybinds |
| 235 |
gar.bind("mod+comma", function() gar.focus_monitor("prev") end) |
| 236 |
gar.bind("mod+period", function() gar.focus_monitor("next") end) |
| 237 |
gar.bind("mod+shift+comma", function() gar.move_to_monitor("prev") end) |
| 238 |
gar.bind("mod+shift+period", function() gar.move_to_monitor("next") end) |
| 239 |
``` |
| 240 |
|
| 241 |
## Keybind Summary |
| 242 |
|
| 243 |
| Keybind | Action | |
| 244 |
|---------|--------| |
| 245 |
| Mod+comma | Focus previous monitor | |
| 246 |
| Mod+period | Focus next monitor | |
| 247 |
| Mod+Shift+comma | Move window to previous monitor | |
| 248 |
| Mod+Shift+period | Move window to next monitor | |
| 249 |
|
| 250 |
## Acceptance Criteria |
| 251 |
|
| 252 |
1. Multiple monitors detected correctly |
| 253 |
2. Each monitor shows its own workspaces |
| 254 |
3. Mod+comma/period moves focus between monitors |
| 255 |
4. Mod+Shift+comma/period moves windows between monitors |
| 256 |
5. Workspace switch on any monitor works |
| 257 |
6. Hotplug works (monitor disconnect/reconnect) |
| 258 |
7. Windows from disconnected monitor move to remaining |
| 259 |
|
| 260 |
## Testing Strategy |
| 261 |
|
| 262 |
```bash |
| 263 |
# With multiple monitors (or Xephyr instances) |
| 264 |
xrandr --listmonitors # Verify detection |
| 265 |
|
| 266 |
# Test focus |
| 267 |
# Mod+period - focus should move to other monitor |
| 268 |
# Mod+comma - focus should move back |
| 269 |
|
| 270 |
# Test move |
| 271 |
# Mod+Shift+period - window should move to other monitor |
| 272 |
|
| 273 |
# Test hotplug (if possible) |
| 274 |
# Disconnect monitor, verify workspaces/windows migrate |
| 275 |
``` |
| 276 |
|
| 277 |
## Notes |
| 278 |
|
| 279 |
- Consider "primary" monitor concept for default workspace |
| 280 |
- Polybar needs per-monitor instances |
| 281 |
- Handle Xinerama fallback for older setups? |
| 282 |
- Monitor arrangement (which is "left"/"right") comes from xrandr |