markdown · 9175 bytes Raw Blame History

Sprint 7: Multi-Monitor Support

Goal: Proper multi-head support with per-monitor workspaces.

Objectives

  • Detect monitors via RandR extension
  • Assign workspaces to monitors
  • Navigate and move windows between monitors
  • Handle monitor hotplug (connect/disconnect)

Prerequisites

  • Sprint 6 complete (IPC system)

Monitor Model

┌─────────────────────────────────────────────────────────────────────┐
│                         WorkspaceManager                             │
│  ┌──────────────┐     ┌──────────────┐     ┌──────────────┐         │
│  │  Monitor 0   │     │  Monitor 1   │     │  Monitor 2   │         │
│  │  "eDP-1"     │     │  "HDMI-1"    │     │  "DP-1"      │         │
│  │              │     │              │     │              │         │
│  │  WS: 1,2,3   │     │  WS: 4,5,6   │     │  WS: 7,8,9   │         │
│  │  Active: 1   │     │  Active: 4   │     │  Active: 7   │         │
│  └──────────────┘     └──────────────┘     └──────────────┘         │
└─────────────────────────────────────────────────────────────────────┘

Tasks

7.1 Monitor Detection

  • Create src/core/monitor.rs
  • Query RandR for connected outputs
  • Get monitor geometry (position, size)
  • Identify primary monitor
  • Handle disabled/mirrored outputs
use x11rb::protocol::randr;

pub struct Monitor {
    pub name: String,
    pub output: randr::Output,
    pub geometry: Rect,
    pub primary: bool,
    pub workspaces: Vec<WorkspaceId>,
    pub active_workspace: WorkspaceId,
}

fn detect_monitors(conn: &impl Connection, root: Window) -> Result<Vec<Monitor>> {
    let resources = conn.randr_get_screen_resources(root)?.reply()?;
    let mut monitors = Vec::new();

    for output in resources.outputs {
        let info = conn.randr_get_output_info(output, 0)?.reply()?;

        if info.connection != randr::Connection::CONNECTED {
            continue;
        }

        if let Some(crtc) = info.crtc {
            let crtc_info = conn.randr_get_crtc_info(crtc, 0)?.reply()?;
            monitors.push(Monitor {
                name: String::from_utf8_lossy(&info.name).to_string(),
                output,
                geometry: Rect {
                    x: crtc_info.x,
                    y: crtc_info.y,
                    width: crtc_info.width,
                    height: crtc_info.height,
                },
                primary: false, // Set later
                workspaces: Vec::new(),
                active_workspace: WorkspaceId(0),
            });
        }
    }

    Ok(monitors)
}

7.2 Workspace-Monitor Assignment

  • Each monitor has a set of workspaces
  • Default: distribute workspaces evenly
  • Lua API: gar.assign_workspace(ws, monitor)
  • Workspace shows on its assigned monitor
impl WorkspaceManager {
    fn assign_workspaces_to_monitors(&mut self) {
        let ws_per_monitor = self.workspaces.len() / self.monitors.len();

        for (i, monitor) in self.monitors.iter_mut().enumerate() {
            let start = i * ws_per_monitor + 1;
            let end = if i == self.monitors.len() - 1 {
                self.workspaces.len()
            } else {
                start + ws_per_monitor
            };

            monitor.workspaces = (start..=end).map(WorkspaceId).collect();
            monitor.active_workspace = WorkspaceId(start);
        }
    }
}

7.3 Focus Across Monitors

  • Implement Mod+comma/period to focus monitor
  • Track focused monitor
  • Focus follows workspace on switch
  • Handle case when monitor has no windows
fn focus_monitor(&mut self, direction: Direction) {
    let current_monitor = self.focused_monitor();
    let target = self.monitor_in_direction(current_monitor, direction)?;

    // Focus the active workspace on target monitor
    let workspace = self.monitors[target].active_workspace;
    if let Some(window) = self.workspaces[workspace].focused {
        self.set_focus(window);
    }

    self.focused_monitor = target;
}

7.4 Move Window to Monitor

  • Implement Mod+Shift+comma/period
  • Remove from current workspace
  • Add to target monitor's active workspace
  • Optionally follow window
fn move_to_monitor(&mut self, direction: Direction) {
    let window = self.focused_window()?;
    let target_monitor = self.monitor_in_direction(self.focused_monitor, direction)?;
    let target_workspace = self.monitors[target_monitor].active_workspace;

    // Move window
    self.current_workspace_mut().tree.remove(window);
    self.workspace_mut(target_workspace).tree.insert(window);

    // Update geometries
    self.apply_layouts()?;
}

7.5 Per-Monitor Workspace Switching

  • Mod+N switches workspace on focused monitor
  • Only switches within monitor's workspace set
  • Or switches to workspace and focuses its monitor
  • Configurable behavior via Lua
fn switch_workspace(&mut self, workspace: WorkspaceId) {
    // Find which monitor this workspace belongs to
    let target_monitor = self.monitors.iter()
        .position(|m| m.workspaces.contains(&workspace));

    match target_monitor {
        Some(monitor_idx) => {
            // Switch workspace on that monitor
            self.monitors[monitor_idx].active_workspace = workspace;

            // Focus that monitor
            self.focused_monitor = monitor_idx;

            // Update visibility
            self.update_workspace_visibility();
        }
        None => {
            tracing::warn!("Workspace {:?} not assigned to any monitor", workspace);
        }
    }
}

7.6 Monitor Hotplug

  • Subscribe to RandR events
  • Handle ScreenChangeNotify
  • Detect added/removed monitors
  • Reassign workspaces as needed
  • Move windows from disconnected monitors
fn handle_randr_event(&mut self, event: randr::ScreenChangeNotifyEvent) {
    let new_monitors = detect_monitors(&self.conn, self.root)?;

    // Find removed monitors
    for old in &self.monitors {
        if !new_monitors.iter().any(|m| m.name == old.name) {
            // Monitor disconnected - move its workspaces/windows
            self.handle_monitor_removed(old);
        }
    }

    // Find added monitors
    for new in &new_monitors {
        if !self.monitors.iter().any(|m| m.name == new.name) {
            // Monitor connected - assign workspaces
            self.handle_monitor_added(new);
        }
    }

    self.monitors = new_monitors;
    self.apply_all_layouts()?;
}

7.7 EWMH Multi-Monitor

  • Set _NET_DESKTOP_GEOMETRY (combined size)
  • Set _NET_DESKTOP_VIEWPORT per workspace
  • Set _NET_WORKAREA per monitor
  • Update on monitor changes

7.8 Lua Configuration

  • gar.assign_workspace(ws, monitor_name)
  • gar.focus_monitor(direction) action
  • gar.move_to_monitor(direction) action
  • Configure focus-follows-workspace behavior
-- Assign specific workspaces to monitors
gar.assign_workspace(1, "eDP-1")
gar.assign_workspace(2, "eDP-1")
gar.assign_workspace(3, "HDMI-1")
gar.assign_workspace(4, "HDMI-1")

-- Keybinds
gar.bind("mod+comma", function() gar.focus_monitor("prev") end)
gar.bind("mod+period", function() gar.focus_monitor("next") end)
gar.bind("mod+shift+comma", function() gar.move_to_monitor("prev") end)
gar.bind("mod+shift+period", function() gar.move_to_monitor("next") end)

Keybind Summary

Keybind Action
Mod+comma Focus previous monitor
Mod+period Focus next monitor
Mod+Shift+comma Move window to previous monitor
Mod+Shift+period Move window to next monitor

Acceptance Criteria

  1. Multiple monitors detected correctly
  2. Each monitor shows its own workspaces
  3. Mod+comma/period moves focus between monitors
  4. Mod+Shift+comma/period moves windows between monitors
  5. Workspace switch on any monitor works
  6. Hotplug works (monitor disconnect/reconnect)
  7. Windows from disconnected monitor move to remaining

Testing Strategy

# With multiple monitors (or Xephyr instances)
xrandr --listmonitors  # Verify detection

# Test focus
# Mod+period - focus should move to other monitor
# Mod+comma - focus should move back

# Test move
# Mod+Shift+period - window should move to other monitor

# Test hotplug (if possible)
# Disconnect monitor, verify workspaces/windows migrate

Notes

  • Consider "primary" monitor concept for default workspace
  • Polybar needs per-monitor instances
  • Handle Xinerama fallback for older setups?
  • Monitor arrangement (which is "left"/"right") comes from xrandr
View source
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