gardesk/gar / 0e5c598

Browse files

Add multi-monitor support and panic mode

Multi-monitor via RandR:
- Detect monitors, distribute workspaces across them
- mod+comma/period to focus prev/next monitor
- mod+shift+comma/period to move window to monitor
- Per-monitor workspace switching, hotplug support
- garctl: focus-monitor, move-to-monitor, get-monitors

Panic mode:
- mod+shift+Escape exits gar immediately
- start-gar.sh script for real X11 testing
- Default config now uses Super key
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
0e5c59844f386dfef35f0c4c7e705b860890c13e
Parents
a392445
Tree
916a212

8 changed files

StatusFile+-
M gar/config/default.lua 11 2
M gar/src/config/lua.rs 28 0
M gar/src/core/mod.rs 109 4
M gar/src/core/monitor.rs 7 1
M gar/src/x11/connection.rs 68 0
M gar/src/x11/events.rs 223 4
M garctl/src/main.rs 21 0
A start-gar.sh 65 0
gar/config/default.luamodified
@@ -9,8 +9,8 @@ gar.set("gap_inner", 8)
99
 gar.set("gap_outer", 8)
1010
 
1111
 -- Mod key: "mod" = Super/Win, "alt" = Alt
12
--- Using alt for testing in nested X (i3 grabs super)
13
-local mod = "alt"
12
+-- Use "mod" for real X session, "alt" for nested testing (Xephyr)
13
+local mod = "mod"
1414
 
1515
 -- Terminal
1616
 gar.bind(mod .. "+Return", function()
@@ -23,6 +23,9 @@ gar.bind(mod .. "+q", gar.close_window)
2323
 -- Reload config
2424
 gar.bind(mod .. "+shift+r", gar.reload)
2525
 
26
+-- PANIC: Exit gar immediately (mod+shift+Escape)
27
+gar.bind(mod .. "+shift+Escape", gar.exit)
28
+
2629
 -- Focus navigation (arrow keys)
2730
 gar.bind(mod .. "+Left", gar.focus("left"))
2831
 gar.bind(mod .. "+Right", gar.focus("right"))
@@ -73,3 +76,9 @@ for i = 1, 9 do
7376
 end
7477
 gar.bind(mod .. "+0", gar.workspace(10))
7578
 gar.bind(mod .. "+shift+0", gar.move_to_workspace(10))
79
+
80
+-- Multi-monitor (comma/period = prev/next)
81
+gar.bind(mod .. "+comma", gar.focus_monitor("prev"))
82
+gar.bind(mod .. "+period", gar.focus_monitor("next"))
83
+gar.bind(mod .. "+shift+comma", gar.move_to_monitor("prev"))
84
+gar.bind(mod .. "+shift+period", gar.move_to_monitor("next"))
gar/src/config/lua.rsmodified
@@ -22,6 +22,8 @@ pub enum Action {
2222
     Exit,
2323
     ToggleFloating,
2424
     CycleFloating,
25
+    FocusMonitor(String),     // "next", "prev", or monitor name
26
+    MoveToMonitor(String),    // "next", "prev", or monitor name
2527
     LuaCallback(usize), // Index into callback registry
2628
 }
2729
 
@@ -289,6 +291,14 @@ impl LuaConfig {
289291
                                 let n: usize = t.get("workspace").unwrap_or(1);
290292
                                 Action::MoveToWorkspace(n)
291293
                             }
294
+                            "focus_monitor" => {
295
+                                let target: String = t.get("target").unwrap_or_default();
296
+                                Action::FocusMonitor(target)
297
+                            }
298
+                            "move_to_monitor" => {
299
+                                let target: String = t.get("target").unwrap_or_default();
300
+                                Action::MoveToMonitor(target)
301
+                            }
292302
                             _ => {
293303
                                 tracing::warn!("Unknown action type: {}", action_type);
294304
                                 return Ok(());
@@ -460,6 +470,24 @@ impl LuaConfig {
460470
         })?;
461471
         gar.set("move_to_workspace", move_fn)?;
462472
 
473
+        // gar.focus_monitor(target) - "next", "prev", or monitor name
474
+        let focus_monitor_fn = self.lua.create_function(|lua, target: String| {
475
+            let t = lua.create_table()?;
476
+            t.set("action", "focus_monitor")?;
477
+            t.set("target", target)?;
478
+            Ok(t)
479
+        })?;
480
+        gar.set("focus_monitor", focus_monitor_fn)?;
481
+
482
+        // gar.move_to_monitor(target) - "next", "prev", or monitor name
483
+        let move_to_monitor_fn = self.lua.create_function(|lua, target: String| {
484
+            let t = lua.create_table()?;
485
+            t.set("action", "move_to_monitor")?;
486
+            t.set("target", target)?;
487
+            Ok(t)
488
+        })?;
489
+        gar.set("move_to_monitor", move_to_monitor_fn)?;
490
+
463491
         Ok(())
464492
     }
465493
 }
gar/src/core/mod.rsmodified
@@ -28,6 +28,7 @@ pub struct WindowManager {
2828
     pub windows: HashMap<XWindow, Window>,
2929
     pub focused_workspace: usize,
3030
     pub focused_window: Option<XWindow>,
31
+    pub focused_monitor: usize,
3132
     pub running: bool,
3233
     pub drag_state: Option<DragState>,
3334
     pub ipc_server: Option<IpcServer>,
@@ -35,7 +36,7 @@ pub struct WindowManager {
3536
 
3637
 impl WindowManager {
3738
     pub fn new(conn: Connection) -> Result<Self> {
38
-        let workspaces = (1..=10)
39
+        let workspaces: Vec<Workspace> = (1..=10)
3940
             .map(|i| Workspace::new(i, i.to_string()))
4041
             .collect();
4142
 
@@ -60,16 +61,53 @@ impl WindowManager {
6061
             }
6162
         };
6263
 
64
+        // Subscribe to RandR events for hotplug
65
+        if let Err(e) = conn.subscribe_randr_events() {
66
+            tracing::warn!("Failed to subscribe to RandR events: {}", e);
67
+        }
68
+
69
+        // Detect monitors
70
+        let mut monitors = conn.detect_monitors().unwrap_or_else(|e| {
71
+            tracing::warn!("Failed to detect monitors: {}, using single screen", e);
72
+            vec![Monitor::new(
73
+                "default".to_string(),
74
+                0,
75
+                Rect::new(0, 0, conn.screen_width, conn.screen_height),
76
+            )]
77
+        });
78
+
79
+        // Ensure at least one monitor
80
+        if monitors.is_empty() {
81
+            monitors.push(Monitor::new(
82
+                "default".to_string(),
83
+                0,
84
+                Rect::new(0, 0, conn.screen_width, conn.screen_height),
85
+            ));
86
+        }
87
+
88
+        // Assign workspaces to monitors
89
+        let ws_count = workspaces.len();
90
+        let mon_count = monitors.len();
91
+        for (i, monitor) in monitors.iter_mut().enumerate() {
92
+            // Distribute workspaces: first monitor gets ws 1-N/M, etc.
93
+            let start = i * ws_count / mon_count;
94
+            let end = (i + 1) * ws_count / mon_count;
95
+            monitor.workspaces = (start..end).collect();
96
+            monitor.active_workspace = start;
97
+            tracing::debug!("Monitor '{}' assigned workspaces {:?}", monitor.name, monitor.workspaces);
98
+        }
99
+
63100
         Ok(Self {
64101
             conn,
65102
             config,
66103
             lua_config,
67104
             lua_state,
68105
             workspaces,
69
-            monitors: Vec::new(),
106
+            monitors,
70107
             windows: HashMap::new(),
71108
             focused_workspace: 0,
72109
             focused_window: None,
110
+            focused_monitor: 0,
73111
             running: true,
74112
             drag_state: None,
75113
             ipc_server,
@@ -84,9 +122,76 @@ impl WindowManager {
84122
         &mut self.workspaces[self.focused_workspace]
85123
     }
86124
 
87
-    /// Get the screen rectangle (full usable area).
125
+    pub fn current_monitor(&self) -> &Monitor {
126
+        &self.monitors[self.focused_monitor]
127
+    }
128
+
129
+    /// Get the rectangle for the focused monitor.
88130
     pub fn screen_rect(&self) -> Rect {
89
-        Rect::new(0, 0, self.conn.screen_width, self.conn.screen_height)
131
+        self.monitors[self.focused_monitor].geometry
132
+    }
133
+
134
+    /// Get the rectangle for a specific workspace's monitor.
135
+    pub fn workspace_rect(&self, workspace_idx: usize) -> Rect {
136
+        self.monitor_for_workspace(workspace_idx)
137
+            .map(|m| m.geometry)
138
+            .unwrap_or_else(|| Rect::new(0, 0, self.conn.screen_width, self.conn.screen_height))
139
+    }
140
+
141
+    /// Find which monitor a workspace belongs to.
142
+    pub fn monitor_for_workspace(&self, workspace_idx: usize) -> Option<&Monitor> {
143
+        self.monitors.iter().find(|m| m.workspaces.contains(&workspace_idx))
144
+    }
145
+
146
+    /// Find the monitor index for a workspace.
147
+    pub fn monitor_idx_for_workspace(&self, workspace_idx: usize) -> Option<usize> {
148
+        self.monitors.iter().position(|m| m.workspaces.contains(&workspace_idx))
149
+    }
150
+
151
+    /// Refresh monitors (called on RandR screen change).
152
+    pub fn refresh_monitors(&mut self) -> Result<()> {
153
+        tracing::info!("Refreshing monitor configuration");
154
+
155
+        let mut new_monitors = self.conn.detect_monitors().unwrap_or_else(|e| {
156
+            tracing::warn!("Failed to detect monitors: {}, keeping current", e);
157
+            return self.monitors.clone();
158
+        });
159
+
160
+        if new_monitors.is_empty() {
161
+            new_monitors.push(Monitor::new(
162
+                "default".to_string(),
163
+                0,
164
+                Rect::new(0, 0, self.conn.screen_width, self.conn.screen_height),
165
+            ));
166
+        }
167
+
168
+        // Reassign workspaces to monitors
169
+        let ws_count = self.workspaces.len();
170
+        let mon_count = new_monitors.len();
171
+        for (i, monitor) in new_monitors.iter_mut().enumerate() {
172
+            let start = i * ws_count / mon_count;
173
+            let end = (i + 1) * ws_count / mon_count;
174
+            monitor.workspaces = (start..end).collect();
175
+            monitor.active_workspace = start;
176
+            tracing::info!("Monitor '{}' assigned workspaces {:?}", monitor.name, monitor.workspaces);
177
+        }
178
+
179
+        self.monitors = new_monitors;
180
+
181
+        // Ensure focused_monitor is valid
182
+        if self.focused_monitor >= self.monitors.len() {
183
+            self.focused_monitor = 0;
184
+        }
185
+
186
+        // Ensure focused_workspace is on the focused monitor
187
+        if !self.monitors[self.focused_monitor].workspaces.contains(&self.focused_workspace) {
188
+            self.focused_workspace = self.monitors[self.focused_monitor].active_workspace;
189
+        }
190
+
191
+        // Re-apply layout for visible workspaces
192
+        self.apply_layout()?;
193
+
194
+        Ok(())
90195
     }
91196
 
92197
     /// Check if a window should be managed (not override-redirect, etc.)
gar/src/core/monitor.rsmodified
@@ -1,18 +1,24 @@
1
+use x11rb::protocol::randr::Output;
2
+
13
 use super::tree::Rect;
24
 
35
 #[derive(Debug, Clone)]
46
 pub struct Monitor {
57
     pub name: String,
8
+    pub output: Output,
69
     pub geometry: Rect,
710
     pub primary: bool,
11
+    /// Workspace indices assigned to this monitor
812
     pub workspaces: Vec<usize>,
13
+    /// Currently active workspace index on this monitor
914
     pub active_workspace: usize,
1015
 }
1116
 
1217
 impl Monitor {
13
-    pub fn new(name: String, geometry: Rect) -> Self {
18
+    pub fn new(name: String, output: Output, geometry: Rect) -> Self {
1419
         Self {
1520
             name,
21
+            output,
1622
             geometry,
1723
             primary: false,
1824
             workspaces: Vec::new(),
gar/src/x11/connection.rsmodified
@@ -589,6 +589,74 @@ impl Connection {
589589
         )?;
590590
         Ok(())
591591
     }
592
+
593
+    /// Detect connected monitors via RandR.
594
+    pub fn detect_monitors(&self) -> Result<Vec<crate::core::Monitor>, Error> {
595
+        use x11rb::protocol::randr::{self, ConnectionExt as RandrExt};
596
+        use crate::core::{Monitor, Rect};
597
+
598
+        let resources = self.conn.randr_get_screen_resources(self.root)?.reply()?;
599
+        let primary = self.conn.randr_get_output_primary(self.root)?.reply()?.output;
600
+
601
+        let mut monitors = Vec::new();
602
+
603
+        for &output in &resources.outputs {
604
+            let info = match self.conn.randr_get_output_info(output, 0)?.reply() {
605
+                Ok(info) => info,
606
+                Err(_) => continue,
607
+            };
608
+
609
+            // Skip disconnected outputs
610
+            if info.connection != randr::Connection::CONNECTED {
611
+                continue;
612
+            }
613
+
614
+            // Skip outputs without a CRTC (not active)
615
+            let crtc = match info.crtc {
616
+                0 => continue,
617
+                c => c,
618
+            };
619
+
620
+            let crtc_info = match self.conn.randr_get_crtc_info(crtc, 0)?.reply() {
621
+                Ok(info) => info,
622
+                Err(_) => continue,
623
+            };
624
+
625
+            let name = String::from_utf8_lossy(&info.name).to_string();
626
+            let geometry = Rect::new(
627
+                crtc_info.x,
628
+                crtc_info.y,
629
+                crtc_info.width,
630
+                crtc_info.height,
631
+            );
632
+
633
+            let mut monitor = Monitor::new(name, output, geometry);
634
+            monitor.primary = output == primary;
635
+
636
+            monitors.push(monitor);
637
+        }
638
+
639
+        // Sort monitors by X position (left to right)
640
+        monitors.sort_by_key(|m| m.geometry.x);
641
+
642
+        tracing::info!("Detected {} monitors: {:?}",
643
+            monitors.len(),
644
+            monitors.iter().map(|m| &m.name).collect::<Vec<_>>()
645
+        );
646
+
647
+        Ok(monitors)
648
+    }
649
+
650
+    /// Subscribe to RandR screen change events.
651
+    pub fn subscribe_randr_events(&self) -> Result<(), Error> {
652
+        use x11rb::protocol::randr::{self, ConnectionExt as RandrExt};
653
+
654
+        self.conn.randr_select_input(
655
+            self.root,
656
+            randr::NotifyMask::SCREEN_CHANGE | randr::NotifyMask::OUTPUT_CHANGE,
657
+        )?;
658
+        Ok(())
659
+    }
592660
 }
593661
 
594662
 impl std::ops::Deref for Connection {
gar/src/x11/events.rsmodified
@@ -167,6 +167,14 @@ impl WindowManager {
167167
             Event::EnterNotify(e) => {
168168
                 tracing::trace!("EnterNotify for window {}", e.event);
169169
             }
170
+            Event::RandrScreenChangeNotify(_) => {
171
+                tracing::info!("RandR screen change detected, refreshing monitors");
172
+                self.refresh_monitors()?;
173
+            }
174
+            Event::RandrNotify(_) => {
175
+                tracing::info!("RandR notify event, refreshing monitors");
176
+                self.refresh_monitors()?;
177
+            }
170178
             _ => {
171179
                 tracing::trace!("Unhandled event: {:?}", event);
172180
             }
@@ -517,6 +525,12 @@ impl WindowManager {
517525
             Action::CycleFloating => {
518526
                 self.cycle_floating()?;
519527
             }
528
+            Action::FocusMonitor(target) => {
529
+                self.focus_monitor(&target)?;
530
+            }
531
+            Action::MoveToMonitor(target) => {
532
+                self.move_to_monitor(&target)?;
533
+            }
520534
             Action::LuaCallback(index) => {
521535
                 if let Err(e) = self.lua_config.execute_callback(index) {
522536
                     tracing::error!("Lua callback error: {}", e);
@@ -613,7 +627,39 @@ impl WindowManager {
613627
     }
614628
 
615629
     fn switch_workspace(&mut self, idx: usize) -> Result<()> {
616
-        if idx >= self.workspaces.len() || idx == self.focused_workspace {
630
+        if idx >= self.workspaces.len() {
631
+            return Ok(());
632
+        }
633
+
634
+        // Find which monitor owns this workspace
635
+        let target_monitor = self.monitor_idx_for_workspace(idx);
636
+
637
+        // If the workspace is on a different monitor, just focus that monitor
638
+        if let Some(mon_idx) = target_monitor {
639
+            if mon_idx != self.focused_monitor {
640
+                tracing::info!("Workspace {} is on monitor {}, switching focus", idx + 1, mon_idx);
641
+                self.focused_monitor = mon_idx;
642
+                self.focused_workspace = idx;
643
+                self.monitors[mon_idx].active_workspace = idx;
644
+                self.conn.set_current_desktop(idx as u32)?;
645
+
646
+                // Focus a window on the target workspace
647
+                let ws = &self.workspaces[idx];
648
+                if let Some(window) = ws.focused
649
+                    .or_else(|| ws.floating.last().copied())
650
+                    .or_else(|| ws.tree.first_window())
651
+                {
652
+                    self.set_focus(window)?;
653
+                } else {
654
+                    self.focused_window = None;
655
+                }
656
+                self.conn.flush()?;
657
+                return Ok(());
658
+            }
659
+        }
660
+
661
+        // Same monitor - switch its active workspace
662
+        if idx == self.focused_workspace {
617663
             return Ok(());
618664
         }
619665
 
@@ -624,8 +670,10 @@ impl WindowManager {
624670
             self.conn.unmap_window(window)?;
625671
         }
626672
 
627
-        // Switch workspace
673
+        // Update monitor's active workspace
674
+        let old_ws = self.focused_workspace;
628675
         self.focused_workspace = idx;
676
+        self.monitors[self.focused_monitor].active_workspace = idx;
629677
 
630678
         // Update EWMH _NET_CURRENT_DESKTOP
631679
         self.conn.set_current_desktop(idx as u32)?;
@@ -651,6 +699,7 @@ impl WindowManager {
651699
             self.focused_window = None;
652700
         }
653701
 
702
+        tracing::debug!("Switched from workspace {} to {}", old_ws + 1, idx + 1);
654703
         self.conn.flush()?;
655704
         Ok(())
656705
     }
@@ -691,12 +740,12 @@ impl WindowManager {
691740
         // Hide the window (it's moving to another workspace)
692741
         self.conn.unmap_window(window)?;
693742
 
694
-        // Insert into target workspace
743
+        // Insert into target workspace (use target monitor's geometry)
695744
         if is_floating {
696745
             self.workspaces[idx].add_floating(window);
697746
         } else {
698747
             let target_focused = self.workspaces[idx].focused;
699
-            let screen = self.screen_rect();
748
+            let screen = self.workspace_rect(idx);
700749
             self.workspaces[idx]
701750
                 .tree
702751
                 .insert_with_rect(window, target_focused, screen);
@@ -896,6 +945,23 @@ impl WindowManager {
896945
                 // Handle subscription in handle_ipc directly
897946
                 Response::success(None)
898947
             }
948
+            "focus_monitor" => {
949
+                let target = args.get("target").and_then(|v| v.as_str()).unwrap_or("next");
950
+                match self.focus_monitor(target) {
951
+                    Ok(_) => Response::success(None),
952
+                    Err(e) => Response::error(e.to_string()),
953
+                }
954
+            }
955
+            "move_to_monitor" => {
956
+                let target = args.get("target").and_then(|v| v.as_str()).unwrap_or("next");
957
+                match self.move_to_monitor(target) {
958
+                    Ok(_) => Response::success(None),
959
+                    Err(e) => Response::error(e.to_string()),
960
+                }
961
+            }
962
+            "get_monitors" => {
963
+                Response::success(Some(self.get_monitors_json()))
964
+            }
899965
             _ => Response::error(format!("Unknown command: {}", command)),
900966
         }
901967
     }
@@ -946,6 +1012,25 @@ impl WindowManager {
9461012
         })
9471013
     }
9481014
 
1015
+    /// Get monitor info as JSON
1016
+    fn get_monitors_json(&self) -> serde_json::Value {
1017
+        serde_json::json!(self.monitors.iter().enumerate().map(|(i, mon)| {
1018
+            serde_json::json!({
1019
+                "name": mon.name,
1020
+                "focused": i == self.focused_monitor,
1021
+                "primary": mon.primary,
1022
+                "geometry": {
1023
+                    "x": mon.geometry.x,
1024
+                    "y": mon.geometry.y,
1025
+                    "width": mon.geometry.width,
1026
+                    "height": mon.geometry.height,
1027
+                },
1028
+                "workspaces": mon.workspaces.iter().map(|ws| ws + 1).collect::<Vec<_>>(),
1029
+                "active_workspace": mon.active_workspace + 1,
1030
+            })
1031
+        }).collect::<Vec<_>>())
1032
+    }
1033
+
9491034
     // Floating window helpers
9501035
 
9511036
     fn is_floating(&self, window: u32) -> bool {
@@ -1006,6 +1091,140 @@ impl WindowManager {
10061091
         Ok(())
10071092
     }
10081093
 
1094
+    /// Focus a different monitor.
1095
+    /// Target can be "next", "prev", "left", "right", or a monitor name.
1096
+    fn focus_monitor(&mut self, target: &str) -> Result<()> {
1097
+        if self.monitors.len() <= 1 {
1098
+            return Ok(());
1099
+        }
1100
+
1101
+        let target_idx = match target.to_lowercase().as_str() {
1102
+            "next" | "right" => (self.focused_monitor + 1) % self.monitors.len(),
1103
+            "prev" | "left" => {
1104
+                if self.focused_monitor == 0 {
1105
+                    self.monitors.len() - 1
1106
+                } else {
1107
+                    self.focused_monitor - 1
1108
+                }
1109
+            }
1110
+            name => {
1111
+                // Find monitor by name
1112
+                match self.monitors.iter().position(|m| m.name.eq_ignore_ascii_case(name)) {
1113
+                    Some(idx) => idx,
1114
+                    None => {
1115
+                        tracing::warn!("Monitor '{}' not found", name);
1116
+                        return Ok(());
1117
+                    }
1118
+                }
1119
+            }
1120
+        };
1121
+
1122
+        if target_idx == self.focused_monitor {
1123
+            return Ok(());
1124
+        }
1125
+
1126
+        tracing::info!("Focusing monitor {}: '{}'", target_idx, self.monitors[target_idx].name);
1127
+        self.focused_monitor = target_idx;
1128
+
1129
+        // Focus the active workspace on that monitor
1130
+        let workspace_idx = self.monitors[target_idx].active_workspace;
1131
+        self.focused_workspace = workspace_idx;
1132
+
1133
+        // Focus a window on that workspace if any
1134
+        if let Some(window) = self.workspaces[workspace_idx].focused
1135
+            .or_else(|| self.workspaces[workspace_idx].floating.last().copied())
1136
+            .or_else(|| self.workspaces[workspace_idx].tree.first_window())
1137
+        {
1138
+            if let Some(old) = self.focused_window {
1139
+                self.conn.grab_button(old)?;
1140
+            }
1141
+            self.set_focus(window)?;
1142
+            self.conn.ungrab_button(window)?;
1143
+        } else {
1144
+            self.focused_window = None;
1145
+        }
1146
+
1147
+        self.conn.flush()?;
1148
+        Ok(())
1149
+    }
1150
+
1151
+    /// Move focused window to another monitor.
1152
+    /// Target can be "next", "prev", "left", "right", or a monitor name.
1153
+    fn move_to_monitor(&mut self, target: &str) -> Result<()> {
1154
+        if self.monitors.len() <= 1 {
1155
+            return Ok(());
1156
+        }
1157
+
1158
+        let Some(window) = self.focused_window else {
1159
+            return Ok(());
1160
+        };
1161
+
1162
+        let target_idx = match target.to_lowercase().as_str() {
1163
+            "next" | "right" => (self.focused_monitor + 1) % self.monitors.len(),
1164
+            "prev" | "left" => {
1165
+                if self.focused_monitor == 0 {
1166
+                    self.monitors.len() - 1
1167
+                } else {
1168
+                    self.focused_monitor - 1
1169
+                }
1170
+            }
1171
+            name => {
1172
+                match self.monitors.iter().position(|m| m.name.eq_ignore_ascii_case(name)) {
1173
+                    Some(idx) => idx,
1174
+                    None => {
1175
+                        tracing::warn!("Monitor '{}' not found", name);
1176
+                        return Ok(());
1177
+                    }
1178
+                }
1179
+            }
1180
+        };
1181
+
1182
+        if target_idx == self.focused_monitor {
1183
+            return Ok(());
1184
+        }
1185
+
1186
+        let is_floating = self.windows.get(&window).map(|w| w.floating).unwrap_or(false);
1187
+        let target_workspace = self.monitors[target_idx].active_workspace;
1188
+
1189
+        tracing::info!("Moving window {} to monitor {}: '{}' (workspace {})",
1190
+            window, target_idx, self.monitors[target_idx].name, target_workspace + 1);
1191
+
1192
+        // Remove from current workspace
1193
+        if is_floating {
1194
+            self.current_workspace_mut().remove_floating(window);
1195
+        } else {
1196
+            self.current_workspace_mut().tree.remove(window);
1197
+        }
1198
+
1199
+        // Update window's workspace
1200
+        if let Some(win) = self.windows.get_mut(&window) {
1201
+            win.workspace = target_workspace;
1202
+        }
1203
+
1204
+        // Add to target workspace
1205
+        if is_floating {
1206
+            self.workspaces[target_workspace].add_floating(window);
1207
+        } else {
1208
+            let target_focused = self.workspaces[target_workspace].focused;
1209
+            let target_rect = self.monitors[target_idx].geometry;
1210
+            self.workspaces[target_workspace].tree.insert_with_rect(window, target_focused, target_rect);
1211
+        }
1212
+
1213
+        // Update EWMH
1214
+        self.conn.set_window_desktop(window, target_workspace as u32)?;
1215
+
1216
+        // Focus follows window to new monitor
1217
+        self.focused_monitor = target_idx;
1218
+        self.focused_workspace = target_workspace;
1219
+        self.workspaces[target_workspace].focused = Some(window);
1220
+
1221
+        // Apply layouts on both monitors
1222
+        self.apply_layout()?;
1223
+
1224
+        self.conn.flush()?;
1225
+        Ok(())
1226
+    }
1227
+
10091228
     /// Cycle through floating windows on the current workspace.
10101229
     fn cycle_floating(&mut self) -> Result<()> {
10111230
         let floating = &self.current_workspace().floating;
garctl/src/main.rsmodified
@@ -59,6 +59,18 @@ enum Command {
5959
     GetFocused,
6060
     /// Get window tree
6161
     GetTree,
62
+    /// Focus monitor (next, prev, or name)
63
+    FocusMonitor {
64
+        /// Target: next, prev, left, right, or monitor name
65
+        target: String,
66
+    },
67
+    /// Move focused window to monitor
68
+    MoveToMonitor {
69
+        /// Target: next, prev, left, right, or monitor name
70
+        target: String,
71
+    },
72
+    /// Get monitor information
73
+    GetMonitors,
6274
 }
6375
 
6476
 fn get_socket_path() -> PathBuf {
@@ -132,6 +144,15 @@ fn main() {
132144
         Command::GetTree => {
133145
             json!({ "command": "get_tree", "args": {} })
134146
         }
147
+        Command::FocusMonitor { target } => {
148
+            json!({ "command": "focus_monitor", "args": { "target": target } })
149
+        }
150
+        Command::MoveToMonitor { target } => {
151
+            json!({ "command": "move_to_monitor", "args": { "target": target } })
152
+        }
153
+        Command::GetMonitors => {
154
+            json!({ "command": "get_monitors", "args": {} })
155
+        }
135156
     };
136157
 
137158
     match send_command(request) {
start-gar.shadded
@@ -0,0 +1,65 @@
1
+#!/usr/bin/env bash
2
+# Start gar window manager on a real X session
3
+#
4
+# PANIC MODE: Alt+Shift+Escape to exit gar immediately
5
+# BACKUP: Ctrl+Alt+F2 to switch to TTY2 if gar freezes
6
+#
7
+# Usage:
8
+#   ./start-gar.sh        # Start on next available display
9
+#   ./start-gar.sh :2     # Start on specific display
10
+#
11
+# To return to Hyprland after exiting:
12
+#   Just log out and log back in, or run: Hyprland
13
+
14
+set -e
15
+
16
+GAR_DIR="$(cd "$(dirname "$0")" && pwd)"
17
+GAR_BIN="$GAR_DIR/target/release/gar"
18
+
19
+# Check if gar is built
20
+if [[ ! -x "$GAR_BIN" ]]; then
21
+    echo "Error: gar not found at $GAR_BIN"
22
+    echo "Run: nix-shell --run 'cargo build --release'"
23
+    exit 1
24
+fi
25
+
26
+# Find display
27
+DISPLAY_NUM="${1:-:1}"
28
+
29
+echo "========================================"
30
+echo "  Starting gar window manager"
31
+echo "========================================"
32
+echo ""
33
+echo "  PANIC MODE: Alt+Shift+Escape"
34
+echo "  TTY ESCAPE: Ctrl+Alt+F2"
35
+echo ""
36
+echo "  Display: $DISPLAY_NUM"
37
+echo "========================================"
38
+echo ""
39
+
40
+# Create xinitrc for gar
41
+XINITRC=$(mktemp)
42
+cat > "$XINITRC" << EOF
43
+#!/bin/sh
44
+# Set up environment
45
+export GAR_LOG=info
46
+
47
+# Start gar
48
+exec $GAR_BIN
49
+EOF
50
+chmod +x "$XINITRC"
51
+
52
+# Trap to clean up
53
+cleanup() {
54
+    rm -f "$XINITRC"
55
+    echo ""
56
+    echo "gar exited. You can now return to your normal session."
57
+}
58
+trap cleanup EXIT
59
+
60
+# Start X with gar
61
+echo "Starting X server with gar..."
62
+echo "Press Ctrl+C here or Alt+Shift+Escape in gar to exit"
63
+echo ""
64
+
65
+startx "$XINITRC" -- "$DISPLAY_NUM" vt$(tty | grep -o '[0-9]*' || echo 7)