gardesk/gar / 6ca3c46

Browse files

Add deferred features: gaps, rules, EWMH, startup adoption

- Gaps: gap_inner between windows, gap_outer around screen edges
- Window rules: gar.rule() for class/instance/title matching
- gar.exec_once() for autostart commands
- EWMH workspace hints for status bar integration
- Adopt existing windows on WM startup
- Cycle floating windows with mod+Tab
- Auto-create user config on first run
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
6ca3c46cf3a7d2bb323f5a74faaf349f926a5bf0
Parents
1572972
Tree
d3c6b83

9 changed files

StatusFile+-
M gar/config/default.lua 6 3
M gar/src/config/lua.rs 126 18
M gar/src/config/mod.rs 1 1
M gar/src/core/mod.rs 234 20
M gar/src/core/window.rs 3 2
M gar/src/core/workspace.rs 28 3
M gar/src/main.rs 8 3
M gar/src/x11/connection.rs 355 2
M gar/src/x11/events.rs 284 79
gar/config/default.luamodified
@@ -5,8 +5,8 @@
55
 gar.set("border_width", 2)
66
 gar.set("border_color_focused", "#5294e2")
77
 gar.set("border_color_unfocused", "#2d2d2d")
8
-gar.set("gap_inner", 0)
9
-gar.set("gap_outer", 0)
8
+gar.set("gap_inner", 8)
9
+gar.set("gap_outer", 8)
1010
 
1111
 -- Mod key: "mod" = Super/Win, "alt" = Alt
1212
 -- Using alt for testing in nested X (i3 grabs super)
@@ -61,7 +61,10 @@ gar.bind(mod .. "+ctrl+j", gar.resize("down", 0.05))
6161
 gar.bind(mod .. "+e", gar.equalize)
6262
 
6363
 -- Toggle floating
64
-gar.bind(mod .. "+shift+space", gar.toggle_floating)
64
+gar.bind(mod .. "+f", gar.toggle_floating)
65
+
66
+-- Cycle through floating windows
67
+gar.bind(mod .. "+Tab", gar.cycle_floating)
6568
 
6669
 -- Workspaces
6770
 for i = 1, 9 do
gar/src/config/lua.rsmodified
@@ -1,3 +1,4 @@
1
+use std::collections::HashSet;
12
 use std::path::PathBuf;
23
 use std::sync::{Arc, Mutex};
34
 
@@ -20,6 +21,7 @@ pub enum Action {
2021
     Reload,
2122
     Exit,
2223
     ToggleFloating,
24
+    CycleFloating,
2325
     LuaCallback(usize), // Index into callback registry
2426
 }
2527
 
@@ -31,11 +33,35 @@ pub struct Keybind {
3133
     pub action: Action,
3234
 }
3335
 
36
+/// Window rule matching criteria
37
+#[derive(Debug, Clone, Default)]
38
+pub struct WindowMatch {
39
+    pub class: Option<String>,
40
+    pub instance: Option<String>,
41
+    pub title: Option<String>,
42
+}
43
+
44
+/// Actions to apply when a rule matches
45
+#[derive(Debug, Clone, Default)]
46
+pub struct RuleActions {
47
+    pub floating: Option<bool>,
48
+    pub workspace: Option<usize>,
49
+}
50
+
51
+/// A window rule: if match criteria are met, apply actions
52
+#[derive(Debug, Clone)]
53
+pub struct WindowRule {
54
+    pub match_criteria: WindowMatch,
55
+    pub actions: RuleActions,
56
+}
57
+
3458
 /// Shared state between Lua and Rust
3559
 pub struct LuaState {
3660
     pub config: Config,
3761
     pub keybinds: Vec<Keybind>,
3862
     pub callbacks: Vec<mlua::RegistryKey>,
63
+    pub rules: Vec<WindowRule>,
64
+    pub exec_once_cmds: HashSet<String>,
3965
 }
4066
 
4167
 impl Default for LuaState {
@@ -44,6 +70,8 @@ impl Default for LuaState {
4470
             config: Config::default(),
4571
             keybinds: Vec::new(),
4672
             callbacks: Vec::new(),
73
+            rules: Vec::new(),
74
+            exec_once_cmds: HashSet::new(),
4775
         }
4876
     }
4977
 }
@@ -71,19 +99,12 @@ impl LuaConfig {
7199
         // Set up the gar global table with all APIs
72100
         self.setup_api()?;
73101
 
74
-        // Find config file
75
-        let config_path = Self::find_config();
102
+        // Find or create config file
103
+        let config_path = Self::find_or_create_config()?;
76104
 
77
-        let source = match config_path {
78
-            Some(path) => {
79
-                tracing::info!("Loading config from {:?}", path);
80
-                std::fs::read_to_string(&path).map_err(|e| mlua::Error::external(e))?
81
-            }
82
-            None => {
83
-                tracing::info!("Using default config");
84
-                include_str!("../../config/default.lua").to_string()
85
-            }
86
-        };
105
+        tracing::info!("Loading config from {:?}", config_path);
106
+        let source = std::fs::read_to_string(&config_path)
107
+            .map_err(|e| mlua::Error::external(e))?;
87108
 
88109
         self.lua.load(&source).exec()?;
89110
 
@@ -96,12 +117,13 @@ impl LuaConfig {
96117
         Ok(())
97118
     }
98119
 
99
-    /// Reload configuration (clears existing keybinds)
120
+    /// Reload configuration (clears existing keybinds and rules)
100121
     pub fn reload(&self) -> LuaResult<()> {
101122
         {
102123
             let mut state = self.state.lock().unwrap();
103124
             state.keybinds.clear();
104125
             state.callbacks.clear();
126
+            state.rules.clear();
105127
             state.config = Config::default();
106128
         }
107129
         self.load()
@@ -118,10 +140,31 @@ impl LuaConfig {
118140
         Ok(())
119141
     }
120142
 
121
-    fn find_config() -> Option<PathBuf> {
122
-        dirs::config_dir()
123
-            .map(|p| p.join("gar/init.lua"))
124
-            .filter(|p| p.exists())
143
+    /// Find user config or create it from defaults if it doesn't exist.
144
+    fn find_or_create_config() -> LuaResult<PathBuf> {
145
+        let config_dir = dirs::config_dir()
146
+            .ok_or_else(|| mlua::Error::external("Could not determine config directory"))?
147
+            .join("gar");
148
+        let config_path = config_dir.join("init.lua");
149
+
150
+        if config_path.exists() {
151
+            return Ok(config_path);
152
+        }
153
+
154
+        // Create config directory if needed
155
+        if !config_dir.exists() {
156
+            std::fs::create_dir_all(&config_dir)
157
+                .map_err(|e| mlua::Error::external(format!("Failed to create config dir: {}", e)))?;
158
+            tracing::info!("Created config directory: {:?}", config_dir);
159
+        }
160
+
161
+        // Write default config
162
+        let default_config = include_str!("../../config/default.lua");
163
+        std::fs::write(&config_path, default_config)
164
+            .map_err(|e| mlua::Error::external(format!("Failed to write default config: {}", e)))?;
165
+        tracing::info!("Created default config at {:?}", config_path);
166
+
167
+        Ok(config_path)
125168
     }
126169
 
127170
     fn setup_api(&self) -> LuaResult<()> {
@@ -136,6 +179,9 @@ impl LuaConfig {
136179
         // gar.exec(cmd)
137180
         self.register_exec(&gar)?;
138181
 
182
+        // gar.rule(match, actions)
183
+        self.register_rule(&gar)?;
184
+
139185
         // Built-in action functions
140186
         self.register_actions(&gar)?;
141187
 
@@ -221,6 +267,7 @@ impl LuaConfig {
221267
                             "exit" => Action::Exit,
222268
                             "equalize" => Action::Equalize,
223269
                             "toggle_floating" => Action::ToggleFloating,
270
+                            "cycle_floating" => Action::CycleFloating,
224271
                             "focus" => {
225272
                                 let dir: String = t.get("direction").unwrap_or_default();
226273
                                 Action::Focus(dir)
@@ -277,7 +324,63 @@ impl LuaConfig {
277324
                 .ok();
278325
             Ok(())
279326
         })?;
280
-        gar.set("exec", exec_fn)
327
+        gar.set("exec", exec_fn)?;
328
+
329
+        // gar.exec_once(cmd) - only run if not already run this session
330
+        let state = Arc::clone(&self.state);
331
+        let exec_once_fn = self.lua.create_function(move |_, cmd: String| {
332
+            let mut state = state.lock().unwrap();
333
+            if state.exec_once_cmds.contains(&cmd) {
334
+                tracing::debug!("exec_once: skipping already-run command: {}", cmd);
335
+                return Ok(());
336
+            }
337
+            tracing::info!("exec_once: {}", cmd);
338
+            state.exec_once_cmds.insert(cmd.clone());
339
+            drop(state); // Release lock before spawning
340
+            std::process::Command::new("sh")
341
+                .arg("-c")
342
+                .arg(&cmd)
343
+                .spawn()
344
+                .ok();
345
+            Ok(())
346
+        })?;
347
+        gar.set("exec_once", exec_once_fn)
348
+    }
349
+
350
+    fn register_rule(&self, gar: &Table) -> LuaResult<()> {
351
+        let state = Arc::clone(&self.state);
352
+        // gar.rule({ class = "Firefox" }, { floating = true, workspace = 2 })
353
+        let rule_fn = self.lua.create_function(move |_, (match_table, actions_table): (Table, Table)| {
354
+            let mut match_criteria = WindowMatch::default();
355
+            let mut actions = RuleActions::default();
356
+
357
+            // Parse match criteria
358
+            if let Ok(class) = match_table.get::<String>("class") {
359
+                match_criteria.class = Some(class);
360
+            }
361
+            if let Ok(instance) = match_table.get::<String>("instance") {
362
+                match_criteria.instance = Some(instance);
363
+            }
364
+            if let Ok(title) = match_table.get::<String>("title") {
365
+                match_criteria.title = Some(title);
366
+            }
367
+
368
+            // Parse actions
369
+            if let Ok(floating) = actions_table.get::<bool>("floating") {
370
+                actions.floating = Some(floating);
371
+            }
372
+            if let Ok(workspace) = actions_table.get::<usize>("workspace") {
373
+                actions.workspace = Some(workspace);
374
+            }
375
+
376
+            let rule = WindowRule { match_criteria, actions };
377
+            tracing::debug!("Registered rule: {:?}", rule);
378
+
379
+            let mut state = state.lock().unwrap();
380
+            state.rules.push(rule);
381
+            Ok(())
382
+        })?;
383
+        gar.set("rule", rule_fn)
281384
     }
282385
 
283386
     fn register_actions(&self, gar: &Table) -> LuaResult<()> {
@@ -306,6 +409,11 @@ impl LuaConfig {
306409
         toggle_floating.set("action", "toggle_floating")?;
307410
         gar.set("toggle_floating", toggle_floating)?;
308411
 
412
+        // gar.cycle_floating
413
+        let cycle_floating = self.lua.create_table()?;
414
+        cycle_floating.set("action", "cycle_floating")?;
415
+        gar.set("cycle_floating", cycle_floating)?;
416
+
309417
         // gar.focus(direction) - creates action
310418
         let focus_fn = self.lua.create_function(|lua, direction: String| {
311419
             let t = lua.create_table()?;
gar/src/config/mod.rsmodified
@@ -1,6 +1,6 @@
11
 mod lua;
22
 
3
-pub use lua::{Action, Keybind, LuaConfig, LuaState};
3
+pub use lua::{Action, Keybind, LuaConfig, LuaState, RuleActions, WindowMatch, WindowRule};
44
 
55
 #[derive(Debug, Clone)]
66
 pub struct Config {
gar/src/core/mod.rsmodified
@@ -12,7 +12,7 @@ use std::collections::HashMap;
1212
 use std::sync::{Arc, Mutex};
1313
 use x11rb::protocol::xproto::Window as XWindow;
1414
 
15
-use crate::config::{Config, LuaConfig, LuaState};
15
+use crate::config::{Config, LuaConfig, LuaState, RuleActions, WindowMatch};
1616
 use crate::x11::Connection;
1717
 use crate::x11::events::DragState;
1818
 use crate::Result;
@@ -96,12 +96,15 @@ impl WindowManager {
9696
             return;
9797
         }
9898
 
99
-        tracing::info!("Managing window {}", window);
99
+        tracing::info!("Managing window {} (tiled)", window);
100100
 
101101
         // Track the window with current workspace
102102
         let win = Window::new(window, self.focused_workspace);
103103
         self.windows.insert(window, win);
104104
 
105
+        // Set EWMH _NET_WM_DESKTOP
106
+        let _ = self.conn.set_window_desktop(window, self.focused_workspace as u32);
107
+
105108
         // Insert into current workspace's tree with smart splitting
106109
         let focused = self.current_workspace().focused;
107110
         let screen = self.screen_rect();
@@ -112,27 +115,144 @@ impl WindowManager {
112115
         self.focused_window = Some(window);
113116
     }
114117
 
118
+    /// Add a window to management on a specific workspace.
119
+    pub fn manage_window_on_workspace(&mut self, window: XWindow, workspace_idx: usize) {
120
+        if !self.should_manage(window) {
121
+            return;
122
+        }
123
+
124
+        tracing::info!("Managing window {} on workspace {} (tiled)", window, workspace_idx + 1);
125
+
126
+        // Track the window
127
+        let win = Window::new(window, workspace_idx);
128
+        self.windows.insert(window, win);
129
+
130
+        // Set EWMH _NET_WM_DESKTOP
131
+        let _ = self.conn.set_window_desktop(window, workspace_idx as u32);
132
+
133
+        // Insert into target workspace's BSP tree
134
+        let focused = self.workspaces[workspace_idx].focused;
135
+        let screen = self.screen_rect();
136
+        self.workspaces[workspace_idx].tree.insert_with_rect(window, focused, screen);
137
+    }
138
+
139
+    /// Add a window to management as a floating window on a specific workspace.
140
+    pub fn manage_window_floating_on_workspace(&mut self, window: XWindow, workspace_idx: usize) {
141
+        if !self.should_manage(window) {
142
+            return;
143
+        }
144
+
145
+        tracing::info!("Managing window {} on workspace {} (floating)", window, workspace_idx + 1);
146
+
147
+        // Calculate floating geometry
148
+        let screen = self.screen_rect();
149
+        let float_width = (screen.width * 4 / 5).max(400);
150
+        let float_height = (screen.height * 4 / 5).max(300);
151
+        let float_x = screen.x + (screen.width as i16 - float_width as i16) / 2;
152
+        let float_y = screen.y + (screen.height as i16 - float_height as i16) / 2;
153
+
154
+        // Track the window with floating state
155
+        let mut win = Window::new(window, workspace_idx);
156
+        win.floating = true;
157
+        win.floating_geometry = Rect::new(float_x, float_y, float_width, float_height);
158
+        self.windows.insert(window, win);
159
+
160
+        // Set EWMH _NET_WM_DESKTOP
161
+        let _ = self.conn.set_window_desktop(window, workspace_idx as u32);
162
+
163
+        // Add to target workspace's floating list
164
+        self.workspaces[workspace_idx].add_floating(window);
165
+    }
166
+
167
+    /// Add a window to management as a floating window.
168
+    pub fn manage_window_floating(&mut self, window: XWindow) {
169
+        if !self.should_manage(window) {
170
+            return;
171
+        }
172
+
173
+        tracing::info!("Managing window {} (floating)", window);
174
+
175
+        // Calculate centered floating geometry
176
+        let screen = self.screen_rect();
177
+        let float_width = 640.min(screen.width.saturating_sub(40));
178
+        let float_height = 480.min(screen.height.saturating_sub(40));
179
+        let float_x = screen.x + (screen.width as i16 - float_width as i16) / 2;
180
+        let float_y = screen.y + (screen.height as i16 - float_height as i16) / 2;
181
+
182
+        // Track the window with floating state
183
+        let mut win = Window::new(window, self.focused_workspace);
184
+        win.floating = true;
185
+        win.floating_geometry = Rect::new(float_x, float_y, float_width, float_height);
186
+        self.windows.insert(window, win);
187
+
188
+        // Set EWMH _NET_WM_DESKTOP
189
+        let _ = self.conn.set_window_desktop(window, self.focused_workspace as u32);
190
+
191
+        // Add to floating list (on top)
192
+        self.current_workspace_mut().add_floating(window);
193
+        self.current_workspace_mut().focused = Some(window);
194
+        self.focused_window = Some(window);
195
+    }
196
+
115197
     /// Remove a window from management.
116198
     pub fn unmanage_window(&mut self, window: XWindow) {
117
-        if self.windows.remove(&window).is_some() {
199
+        if let Some(win) = self.windows.remove(&window) {
118200
             tracing::info!("Unmanaging window {}", window);
119201
 
120
-            // Remove from workspace tree
121
-            self.current_workspace_mut().tree.remove(window);
202
+            if win.floating {
203
+                // Remove from floating list
204
+                self.current_workspace_mut().remove_floating(window);
205
+            } else {
206
+                // Remove from BSP tree
207
+                self.current_workspace_mut().tree.remove(window);
208
+            }
122209
 
123210
             // Update focus if this was the focused window
124211
             if self.focused_window == Some(window) {
125
-                self.focused_window = self.current_workspace().tree.first_window();
212
+                // Try to focus another window (prefer tiled, then floating)
213
+                self.focused_window = self.current_workspace().tree.first_window()
214
+                    .or_else(|| self.current_workspace().floating.last().copied());
126215
                 self.current_workspace_mut().focused = self.focused_window;
127216
             }
128217
         }
129218
     }
130219
 
220
+    /// Check window rules and return actions to apply.
221
+    pub fn check_rules(&self, window: XWindow) -> RuleActions {
222
+        let state = self.lua_state.lock().unwrap();
223
+
224
+        // Get window properties
225
+        let (instance, class) = self.conn.get_wm_class(window).unwrap_or_default();
226
+        let title = self.conn.get_window_title(window).unwrap_or_default();
227
+
228
+        tracing::debug!("Checking rules for window {}: class={}, instance={}, title={}",
229
+            window, class, instance, title);
230
+
231
+        let mut result = RuleActions::default();
232
+
233
+        for rule in &state.rules {
234
+            let matches = rule_matches(&rule.match_criteria, &class, &instance, &title);
235
+            if matches {
236
+                tracing::info!("Rule matched for window {}: {:?}", window, rule.actions);
237
+                // Merge actions (later rules override earlier ones)
238
+                if rule.actions.floating.is_some() {
239
+                    result.floating = rule.actions.floating;
240
+                }
241
+                if rule.actions.workspace.is_some() {
242
+                    result.workspace = rule.actions.workspace;
243
+                }
244
+            }
245
+        }
246
+
247
+        result
248
+    }
249
+
131250
     /// Set focus to a window.
132251
     pub fn set_focus(&mut self, window: XWindow) -> Result<()> {
133252
         self.focused_window = Some(window);
134253
         self.current_workspace_mut().focused = Some(window);
135254
         self.conn.set_focus(window)?;
255
+        self.conn.set_active_window(Some(window))?;
136256
         self.update_borders()?;
137257
         Ok(())
138258
     }
@@ -144,7 +264,8 @@ impl WindowManager {
144264
         let unfocused_color = self.config.border_color_unfocused;
145265
         let border_width = self.config.border_width;
146266
 
147
-        for &window in self.current_workspace().tree.windows().iter() {
267
+        // Update borders for all windows (tiled + floating)
268
+        for window in self.current_workspace().all_windows() {
148269
             let color = if Some(window) == focused {
149270
                 focused_color
150271
             } else {
@@ -156,33 +277,126 @@ impl WindowManager {
156277
     }
157278
 
158279
     /// Apply the current layout to all windows.
280
+    /// Stacking order: tiled windows at bottom, floating windows on top (in list order).
159281
     pub fn apply_layout(&mut self) -> Result<()> {
282
+        use x11rb::protocol::xproto::{ConfigureWindowAux, ConnectionExt, StackMode};
283
+
160284
         let border_width = self.config.border_width;
285
+        let gap_outer = self.config.gap_outer as i16;
286
+        let gap_inner = self.config.gap_inner as i16;
287
+        let half_gap = gap_inner / 2;
161288
 
162
-        // Calculate usable area (screen minus borders)
289
+        // Apply outer gap to screen rect
163290
         let screen = self.screen_rect();
291
+        let work_area = Rect::new(
292
+            screen.x + gap_outer,
293
+            screen.y + gap_outer,
294
+            screen.width.saturating_sub(2 * gap_outer as u16),
295
+            screen.height.saturating_sub(2 * gap_outer as u16),
296
+        );
297
+
298
+        tracing::debug!(
299
+            "apply_layout: screen={:?}, work_area={:?}, tiled_count={}, floating_count={}",
300
+            screen,
301
+            work_area,
302
+            self.current_workspace().tree.window_count(),
303
+            self.current_workspace().floating.len()
304
+        );
164305
 
165
-        // Get geometries from the tree
166
-        let geometries = self.current_workspace().tree.calculate_geometries(screen);
306
+        // 1. Configure tiled windows from the BSP tree
307
+        let geometries = self.current_workspace().tree.calculate_geometries(work_area);
308
+        for (window, rect) in &geometries {
309
+            // Apply inner gap: shrink each window by half_gap on each side
310
+            let gapped_x = rect.x + half_gap;
311
+            let gapped_y = rect.y + half_gap;
312
+            let gapped_width = rect.width.saturating_sub(gap_inner as u16);
313
+            let gapped_height = rect.height.saturating_sub(gap_inner as u16);
167314
 
168
-        // Apply geometries to windows
169
-        for (window, rect) in geometries {
170
-            // Account for border width in geometry
171
-            let adjusted_width = rect.width.saturating_sub(2 * border_width as u16);
172
-            let adjusted_height = rect.height.saturating_sub(2 * border_width as u16);
315
+            // Account for border width
316
+            let final_width = gapped_width.saturating_sub(2 * border_width as u16);
317
+            let final_height = gapped_height.saturating_sub(2 * border_width as u16);
318
+
319
+            tracing::debug!(
320
+                "apply_layout: TILED window={} at ({}, {}) size {}x{}",
321
+                window, gapped_x, gapped_y, final_width.max(1), final_height.max(1)
322
+            );
173323
 
174324
             self.conn.configure_window(
175
-                window,
176
-                rect.x,
177
-                rect.y,
178
-                adjusted_width.max(1),
179
-                adjusted_height.max(1),
325
+                *window,
326
+                gapped_x,
327
+                gapped_y,
328
+                final_width.max(1),
329
+                final_height.max(1),
180330
                 border_width,
181331
             )?;
182332
         }
183333
 
334
+        // 2. Configure floating windows and stack them above tiled
335
+        // Get floating window IDs (in stacking order: first = bottom, last = top)
336
+        let floating_ids: Vec<XWindow> = self.current_workspace().floating.clone();
337
+
338
+        for window_id in floating_ids {
339
+            // Get the window's floating geometry from our state
340
+            if let Some(win) = self.windows.get(&window_id) {
341
+                let geom = win.floating_geometry;
342
+                let adjusted_width = geom.width.saturating_sub(2 * border_width as u16);
343
+                let adjusted_height = geom.height.saturating_sub(2 * border_width as u16);
344
+
345
+                tracing::debug!(
346
+                    "apply_layout: FLOATING window={} at ({}, {}) size {}x{} (raising)",
347
+                    window_id, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1)
348
+                );
349
+
350
+                // Configure geometry
351
+                self.conn.configure_window(
352
+                    window_id,
353
+                    geom.x,
354
+                    geom.y,
355
+                    adjusted_width.max(1),
356
+                    adjusted_height.max(1),
357
+                    border_width,
358
+                )?;
359
+
360
+                // Raise to top of stack (each subsequent window goes above the previous)
361
+                let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
362
+                self.conn.conn.configure_window(window_id, &aux)?;
363
+            } else {
364
+                tracing::warn!("apply_layout: floating window {} not in windows map!", window_id);
365
+            }
366
+        }
367
+
184368
         self.update_borders()?;
185369
         self.conn.flush()?;
186370
         Ok(())
187371
     }
188372
 }
373
+
374
+/// Check if window properties match rule criteria (case-insensitive substring match).
375
+fn rule_matches(criteria: &WindowMatch, class: &str, instance: &str, title: &str) -> bool {
376
+    let class_lower = class.to_lowercase();
377
+    let instance_lower = instance.to_lowercase();
378
+    let title_lower = title.to_lowercase();
379
+
380
+    // All specified criteria must match
381
+    if let Some(ref c) = criteria.class {
382
+        let c_lower: String = c.to_lowercase();
383
+        if !class_lower.contains(&c_lower) {
384
+            return false;
385
+        }
386
+    }
387
+    if let Some(ref i) = criteria.instance {
388
+        let i_lower: String = i.to_lowercase();
389
+        if !instance_lower.contains(&i_lower) {
390
+            return false;
391
+        }
392
+    }
393
+    if let Some(ref t) = criteria.title {
394
+        let t_lower: String = t.to_lowercase();
395
+        if !title_lower.contains(&t_lower) {
396
+            return false;
397
+        }
398
+    }
399
+
400
+    // At least one criterion must be specified
401
+    criteria.class.is_some() || criteria.instance.is_some() || criteria.title.is_some()
402
+}
gar/src/core/window.rsmodified
@@ -5,7 +5,8 @@ use super::tree::Rect;
55
 #[derive(Debug, Clone)]
66
 pub struct Window {
77
     pub id: XWindow,
8
-    pub geometry: Rect,
8
+    /// Geometry for floating mode (position and size when floating)
9
+    pub floating_geometry: Rect,
910
     pub mapped: bool,
1011
     pub focused: bool,
1112
     pub floating: bool,
@@ -17,7 +18,7 @@ impl Window {
1718
     pub fn new(id: XWindow, workspace: usize) -> Self {
1819
         Self {
1920
             id,
20
-            geometry: Rect::new(0, 0, 1, 1),
21
+            floating_geometry: Rect::new(0, 0, 640, 480),
2122
             mapped: false,
2223
             focused: false,
2324
             floating: false,
gar/src/core/workspace.rsmodified
@@ -1,14 +1,14 @@
11
 use x11rb::protocol::xproto::Window as XWindow;
22
 
33
 use super::tree::Node;
4
-use super::Window;
54
 
65
 #[derive(Debug)]
76
 pub struct Workspace {
87
     pub id: usize,
98
     pub name: String,
109
     pub tree: Node,
11
-    pub floating: Vec<Window>,
10
+    /// Floating window IDs in stacking order (first = bottom, last = top)
11
+    pub floating: Vec<XWindow>,
1212
     pub focused: Option<XWindow>,
1313
     pub visible: bool,
1414
 }
@@ -27,11 +27,36 @@ impl Workspace {
2727
 
2828
     pub fn all_windows(&self) -> Vec<XWindow> {
2929
         let mut windows = self.tree.windows();
30
-        windows.extend(self.floating.iter().map(|w| w.id));
30
+        windows.extend(self.floating.iter().copied());
3131
         windows
3232
     }
3333
 
3434
     pub fn is_empty(&self) -> bool {
3535
         self.tree.is_empty() && self.floating.is_empty()
3636
     }
37
+
38
+    /// Add a window to the floating list (on top)
39
+    pub fn add_floating(&mut self, window: XWindow) {
40
+        if !self.floating.contains(&window) {
41
+            self.floating.push(window);
42
+        }
43
+    }
44
+
45
+    /// Remove a window from the floating list
46
+    pub fn remove_floating(&mut self, window: XWindow) {
47
+        self.floating.retain(|&w| w != window);
48
+    }
49
+
50
+    /// Raise a floating window to the top of the stacking order
51
+    pub fn raise_floating(&mut self, window: XWindow) {
52
+        if let Some(pos) = self.floating.iter().position(|&w| w == window) {
53
+            self.floating.remove(pos);
54
+            self.floating.push(window);
55
+        }
56
+    }
57
+
58
+    /// Check if a window is in the floating list
59
+    pub fn is_floating(&self, window: XWindow) -> bool {
60
+        self.floating.contains(&window)
61
+    }
3762
 }
gar/src/main.rsmodified
@@ -1,18 +1,23 @@
1
+use std::fs::File;
12
 use tracing_subscriber::{fmt, prelude::*, EnvFilter};
23
 
34
 use gar::x11::Connection;
45
 use gar::WindowManager;
56
 
67
 fn main() {
7
-    // Set up logging
8
-    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
8
+    // Set up logging to both stdout and file
9
+    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
10
+
11
+    let log_file = File::create("/tmp/gar.log").expect("Failed to create log file");
912
 
1013
     tracing_subscriber::registry()
11
-        .with(fmt::layer())
14
+        .with(fmt::layer().with_writer(std::io::stdout))
15
+        .with(fmt::layer().with_writer(log_file).with_ansi(false))
1216
         .with(filter)
1317
         .init();
1418
 
1519
     tracing::info!("gar {} starting", env!("CARGO_PKG_VERSION"));
20
+    tracing::info!("Logging to /tmp/gar.log");
1621
 
1722
     if let Err(e) = run() {
1823
         tracing::error!("Fatal error: {}", e);
gar/src/x11/connection.rsmodified
@@ -1,7 +1,8 @@
11
 use x11rb::connection::Connection as X11Connection;
22
 use x11rb::protocol::xproto::{
3
-    ButtonIndex, ChangeWindowAttributesAux, ConfigureWindowAux, ConnectionExt, EventMask,
4
-    GrabMode, InputFocus, ModMask, Screen, Window,
3
+    Atom, AtomEnum, ButtonIndex, ChangeWindowAttributesAux, ClientMessageData,
4
+    ClientMessageEvent, ConfigureWindowAux, ConnectionExt, EventMask, GrabMode, InputFocus,
5
+    ModMask, Screen, Window,
56
 };
67
 use x11rb::rust_connection::RustConnection;
78
 use x11rb::wrapper::ConnectionExt as WrapperConnectionExt;
@@ -15,6 +16,28 @@ pub struct Connection {
1516
     pub root: Window,
1617
     pub screen_width: u16,
1718
     pub screen_height: u16,
19
+    // ICCCM atoms
20
+    pub wm_protocols: Atom,
21
+    pub wm_delete_window: Atom,
22
+    pub wm_transient_for: Atom,
23
+    // EWMH atoms for window types
24
+    pub net_wm_window_type: Atom,
25
+    pub net_wm_window_type_dialog: Atom,
26
+    pub net_wm_window_type_utility: Atom,
27
+    pub net_wm_window_type_toolbar: Atom,
28
+    pub net_wm_window_type_splash: Atom,
29
+    pub net_wm_window_type_notification: Atom,
30
+    // EWMH atoms for window state
31
+    pub net_wm_state: Atom,
32
+    pub net_wm_state_modal: Atom,
33
+    // EWMH atoms for workspaces
34
+    pub net_supported: Atom,
35
+    pub net_number_of_desktops: Atom,
36
+    pub net_current_desktop: Atom,
37
+    pub net_desktop_names: Atom,
38
+    pub net_wm_desktop: Atom,
39
+    pub net_active_window: Atom,
40
+    pub utf8_string: Atom,
1841
 }
1942
 
2043
 impl Connection {
@@ -31,6 +54,32 @@ impl Connection {
3154
         let screen_width = screen.width_in_pixels;
3255
         let screen_height = screen.height_in_pixels;
3356
 
57
+        // Intern ICCCM atoms
58
+        let wm_protocols = conn.intern_atom(false, b"WM_PROTOCOLS")?.reply()?.atom;
59
+        let wm_delete_window = conn.intern_atom(false, b"WM_DELETE_WINDOW")?.reply()?.atom;
60
+        let wm_transient_for = conn.intern_atom(false, b"WM_TRANSIENT_FOR")?.reply()?.atom;
61
+
62
+        // Intern EWMH atoms for window types
63
+        let net_wm_window_type = conn.intern_atom(false, b"_NET_WM_WINDOW_TYPE")?.reply()?.atom;
64
+        let net_wm_window_type_dialog = conn.intern_atom(false, b"_NET_WM_WINDOW_TYPE_DIALOG")?.reply()?.atom;
65
+        let net_wm_window_type_utility = conn.intern_atom(false, b"_NET_WM_WINDOW_TYPE_UTILITY")?.reply()?.atom;
66
+        let net_wm_window_type_toolbar = conn.intern_atom(false, b"_NET_WM_WINDOW_TYPE_TOOLBAR")?.reply()?.atom;
67
+        let net_wm_window_type_splash = conn.intern_atom(false, b"_NET_WM_WINDOW_TYPE_SPLASH")?.reply()?.atom;
68
+        let net_wm_window_type_notification = conn.intern_atom(false, b"_NET_WM_WINDOW_TYPE_NOTIFICATION")?.reply()?.atom;
69
+
70
+        // Intern EWMH atoms for window state
71
+        let net_wm_state = conn.intern_atom(false, b"_NET_WM_STATE")?.reply()?.atom;
72
+        let net_wm_state_modal = conn.intern_atom(false, b"_NET_WM_STATE_MODAL")?.reply()?.atom;
73
+
74
+        // Intern EWMH atoms for workspaces
75
+        let net_supported = conn.intern_atom(false, b"_NET_SUPPORTED")?.reply()?.atom;
76
+        let net_number_of_desktops = conn.intern_atom(false, b"_NET_NUMBER_OF_DESKTOPS")?.reply()?.atom;
77
+        let net_current_desktop = conn.intern_atom(false, b"_NET_CURRENT_DESKTOP")?.reply()?.atom;
78
+        let net_desktop_names = conn.intern_atom(false, b"_NET_DESKTOP_NAMES")?.reply()?.atom;
79
+        let net_wm_desktop = conn.intern_atom(false, b"_NET_WM_DESKTOP")?.reply()?.atom;
80
+        let net_active_window = conn.intern_atom(false, b"_NET_ACTIVE_WINDOW")?.reply()?.atom;
81
+        let utf8_string = conn.intern_atom(false, b"UTF8_STRING")?.reply()?.atom;
82
+
3483
         tracing::info!(
3584
             "Connected to X server, screen {}x{}",
3685
             screen_width,
@@ -43,6 +92,24 @@ impl Connection {
4392
             root,
4493
             screen_width,
4594
             screen_height,
95
+            wm_protocols,
96
+            wm_delete_window,
97
+            wm_transient_for,
98
+            net_wm_window_type,
99
+            net_wm_window_type_dialog,
100
+            net_wm_window_type_utility,
101
+            net_wm_window_type_toolbar,
102
+            net_wm_window_type_splash,
103
+            net_wm_window_type_notification,
104
+            net_wm_state,
105
+            net_wm_state_modal,
106
+            net_supported,
107
+            net_number_of_desktops,
108
+            net_current_desktop,
109
+            net_desktop_names,
110
+            net_wm_desktop,
111
+            net_active_window,
112
+            utf8_string,
46113
         })
47114
     }
48115
 
@@ -128,6 +195,36 @@ impl Connection {
128195
         Ok(())
129196
     }
130197
 
198
+    /// Grab Alt+Button1 and Alt+Button3 on root for floating window move/resize.
199
+    pub fn grab_mod_buttons(&self) -> Result<(), Error> {
200
+        let numlock = ModMask::M2;
201
+        let capslock = ModMask::LOCK;
202
+
203
+        // Grab Alt+Button1 (move) and Alt+Button3 (resize) with NumLock/CapsLock variants
204
+        for button in [ButtonIndex::M1, ButtonIndex::M3] {
205
+            for mods in [
206
+                ModMask::M1,
207
+                ModMask::M1 | numlock,
208
+                ModMask::M1 | capslock,
209
+                ModMask::M1 | numlock | capslock,
210
+            ] {
211
+                self.conn.grab_button(
212
+                    false,
213
+                    self.root,
214
+                    EventMask::BUTTON_PRESS | EventMask::BUTTON_RELEASE | EventMask::BUTTON_MOTION,
215
+                    GrabMode::ASYNC,
216
+                    GrabMode::ASYNC,
217
+                    x11rb::NONE,
218
+                    x11rb::NONE,
219
+                    button,
220
+                    mods,
221
+                )?;
222
+            }
223
+        }
224
+        tracing::debug!("Grabbed Alt+Button1/Button3 on root for floating move/resize");
225
+        Ok(())
226
+    }
227
+
131228
     /// Set input focus to a window.
132229
     pub fn set_focus(&self, window: Window) -> Result<(), Error> {
133230
         self.conn
@@ -236,6 +333,262 @@ impl Connection {
236333
         self.conn.sync()?;
237334
         Ok(())
238335
     }
336
+
337
+    /// Check if a window supports the WM_DELETE_WINDOW protocol.
338
+    pub fn supports_delete_window(&self, window: Window) -> bool {
339
+        match self.conn.get_property(
340
+            false,
341
+            window,
342
+            self.wm_protocols,
343
+            AtomEnum::ATOM,
344
+            0,
345
+            1024,
346
+        ) {
347
+            Ok(cookie) => {
348
+                if let Ok(reply) = cookie.reply() {
349
+                    if reply.type_ == u32::from(AtomEnum::ATOM) && reply.format == 32 {
350
+                        // Parse atoms from the value (32-bit little-endian values)
351
+                        for chunk in reply.value.chunks_exact(4) {
352
+                            let atom = u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
353
+                            if atom == self.wm_delete_window {
354
+                                return true;
355
+                            }
356
+                        }
357
+                    }
358
+                }
359
+                false
360
+            }
361
+            Err(_) => false,
362
+        }
363
+    }
364
+
365
+    /// Send WM_DELETE_WINDOW client message to gracefully close a window.
366
+    pub fn send_delete_window(&self, window: Window) -> Result<(), Error> {
367
+        let data = ClientMessageData::from([
368
+            self.wm_delete_window,
369
+            CURRENT_TIME,
370
+            0,
371
+            0,
372
+            0,
373
+        ]);
374
+
375
+        let event = ClientMessageEvent::new(32, window, self.wm_protocols, data);
376
+
377
+        self.conn.send_event(false, window, EventMask::NO_EVENT, event)?;
378
+        Ok(())
379
+    }
380
+
381
+    /// Check if a window should automatically float based on ICCCM/EWMH hints.
382
+    /// Returns true for dialogs, transients, utilities, toolbars, splashes, etc.
383
+    pub fn should_float(&self, window: Window) -> bool {
384
+        // 1. Check WM_TRANSIENT_FOR (ICCCM) - dialogs and popups
385
+        if let Ok(cookie) = self.conn.get_property(
386
+            false,
387
+            window,
388
+            self.wm_transient_for,
389
+            AtomEnum::WINDOW,
390
+            0,
391
+            1,
392
+        ) {
393
+            if let Ok(reply) = cookie.reply() {
394
+                if reply.type_ == u32::from(AtomEnum::WINDOW) && !reply.value.is_empty() {
395
+                    tracing::debug!("Window {} has WM_TRANSIENT_FOR, should float", window);
396
+                    return true;
397
+                }
398
+            }
399
+        }
400
+
401
+        // 2. Check _NET_WM_WINDOW_TYPE (EWMH)
402
+        if let Ok(cookie) = self.conn.get_property(
403
+            false,
404
+            window,
405
+            self.net_wm_window_type,
406
+            AtomEnum::ATOM,
407
+            0,
408
+            32,
409
+        ) {
410
+            if let Ok(reply) = cookie.reply() {
411
+                if reply.type_ == u32::from(AtomEnum::ATOM) && reply.format == 32 {
412
+                    let float_types = [
413
+                        self.net_wm_window_type_dialog,
414
+                        self.net_wm_window_type_utility,
415
+                        self.net_wm_window_type_toolbar,
416
+                        self.net_wm_window_type_splash,
417
+                        self.net_wm_window_type_notification,
418
+                    ];
419
+
420
+                    for chunk in reply.value.chunks_exact(4) {
421
+                        let atom = u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
422
+                        if float_types.contains(&atom) {
423
+                            tracing::debug!("Window {} has floating window type, should float", window);
424
+                            return true;
425
+                        }
426
+                    }
427
+                }
428
+            }
429
+        }
430
+
431
+        // 3. Check _NET_WM_STATE for modal windows
432
+        if let Ok(cookie) = self.conn.get_property(
433
+            false,
434
+            window,
435
+            self.net_wm_state,
436
+            AtomEnum::ATOM,
437
+            0,
438
+            32,
439
+        ) {
440
+            if let Ok(reply) = cookie.reply() {
441
+                if reply.type_ == u32::from(AtomEnum::ATOM) && reply.format == 32 {
442
+                    for chunk in reply.value.chunks_exact(4) {
443
+                        let atom = u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
444
+                        if atom == self.net_wm_state_modal {
445
+                            tracing::debug!("Window {} is modal, should float", window);
446
+                            return true;
447
+                        }
448
+                    }
449
+                }
450
+            }
451
+        }
452
+
453
+        false
454
+    }
455
+
456
+    /// Get WM_CLASS property (instance, class) for a window.
457
+    pub fn get_wm_class(&self, window: Window) -> Option<(String, String)> {
458
+        let wm_class_atom = self.conn.intern_atom(false, b"WM_CLASS").ok()?.reply().ok()?.atom;
459
+        let string_atom = self.conn.intern_atom(false, b"STRING").ok()?.reply().ok()?.atom;
460
+
461
+        let cookie = self.conn.get_property(false, window, wm_class_atom, string_atom, 0, 1024).ok()?;
462
+        let reply = cookie.reply().ok()?;
463
+
464
+        if reply.format != 8 {
465
+            return None;
466
+        }
467
+
468
+        // WM_CLASS is two null-terminated strings: instance\0class\0
469
+        let value = reply.value;
470
+        let mut parts = value.split(|&b| b == 0).filter(|s| !s.is_empty());
471
+
472
+        let instance = parts.next().and_then(|s| String::from_utf8(s.to_vec()).ok())?;
473
+        let class = parts.next().and_then(|s| String::from_utf8(s.to_vec()).ok())?;
474
+
475
+        Some((instance, class))
476
+    }
477
+
478
+    /// Get _NET_WM_NAME or WM_NAME for a window.
479
+    pub fn get_window_title(&self, window: Window) -> Option<String> {
480
+        // Try _NET_WM_NAME first (UTF-8)
481
+        let net_wm_name = self.conn.intern_atom(false, b"_NET_WM_NAME").ok()?.reply().ok()?.atom;
482
+        let utf8_string = self.conn.intern_atom(false, b"UTF8_STRING").ok()?.reply().ok()?.atom;
483
+
484
+        if let Ok(cookie) = self.conn.get_property(false, window, net_wm_name, utf8_string, 0, 1024) {
485
+            if let Ok(reply) = cookie.reply() {
486
+                if reply.format == 8 && !reply.value.is_empty() {
487
+                    return String::from_utf8(reply.value).ok();
488
+                }
489
+            }
490
+        }
491
+
492
+        // Fall back to WM_NAME
493
+        let wm_name = self.conn.intern_atom(false, b"WM_NAME").ok()?.reply().ok()?.atom;
494
+        let string_atom = self.conn.intern_atom(false, b"STRING").ok()?.reply().ok()?.atom;
495
+
496
+        let cookie = self.conn.get_property(false, window, wm_name, string_atom, 0, 1024).ok()?;
497
+        let reply = cookie.reply().ok()?;
498
+
499
+        if reply.format == 8 {
500
+            return String::from_utf8(reply.value).ok();
501
+        }
502
+
503
+        None
504
+    }
505
+
506
+    /// Set _NET_SUPPORTED on root window to advertise supported EWMH atoms.
507
+    pub fn set_ewmh_supported(&self) -> Result<(), Error> {
508
+        let supported = [
509
+            self.net_supported,
510
+            self.net_number_of_desktops,
511
+            self.net_current_desktop,
512
+            self.net_desktop_names,
513
+            self.net_wm_desktop,
514
+            self.net_active_window,
515
+        ];
516
+        self.conn.change_property32(
517
+            x11rb::protocol::xproto::PropMode::REPLACE,
518
+            self.root,
519
+            self.net_supported,
520
+            AtomEnum::ATOM,
521
+            &supported,
522
+        )?;
523
+        Ok(())
524
+    }
525
+
526
+    /// Set _NET_NUMBER_OF_DESKTOPS on root window.
527
+    pub fn set_number_of_desktops(&self, count: u32) -> Result<(), Error> {
528
+        self.conn.change_property32(
529
+            x11rb::protocol::xproto::PropMode::REPLACE,
530
+            self.root,
531
+            self.net_number_of_desktops,
532
+            AtomEnum::CARDINAL,
533
+            &[count],
534
+        )?;
535
+        Ok(())
536
+    }
537
+
538
+    /// Set _NET_CURRENT_DESKTOP on root window.
539
+    pub fn set_current_desktop(&self, index: u32) -> Result<(), Error> {
540
+        self.conn.change_property32(
541
+            x11rb::protocol::xproto::PropMode::REPLACE,
542
+            self.root,
543
+            self.net_current_desktop,
544
+            AtomEnum::CARDINAL,
545
+            &[index],
546
+        )?;
547
+        Ok(())
548
+    }
549
+
550
+    /// Set _NET_DESKTOP_NAMES on root window.
551
+    pub fn set_desktop_names(&self, names: &[String]) -> Result<(), Error> {
552
+        // Names are null-terminated UTF8 strings concatenated together
553
+        let mut data: Vec<u8> = Vec::new();
554
+        for name in names {
555
+            data.extend_from_slice(name.as_bytes());
556
+            data.push(0);
557
+        }
558
+        self.conn.change_property8(
559
+            x11rb::protocol::xproto::PropMode::REPLACE,
560
+            self.root,
561
+            self.net_desktop_names,
562
+            self.utf8_string,
563
+            &data,
564
+        )?;
565
+        Ok(())
566
+    }
567
+
568
+    /// Set _NET_ACTIVE_WINDOW on root window.
569
+    pub fn set_active_window(&self, window: Option<Window>) -> Result<(), Error> {
570
+        let win = window.unwrap_or(0);
571
+        self.conn.change_property32(
572
+            x11rb::protocol::xproto::PropMode::REPLACE,
573
+            self.root,
574
+            self.net_active_window,
575
+            AtomEnum::WINDOW,
576
+            &[win],
577
+        )?;
578
+        Ok(())
579
+    }
580
+
581
+    /// Set _NET_WM_DESKTOP on a window.
582
+    pub fn set_window_desktop(&self, window: Window, desktop: u32) -> Result<(), Error> {
583
+        self.conn.change_property32(
584
+            x11rb::protocol::xproto::PropMode::REPLACE,
585
+            window,
586
+            self.net_wm_desktop,
587
+            AtomEnum::CARDINAL,
588
+            &[desktop],
589
+        )?;
590
+        Ok(())
591
+    }
239592
 }
240593
 
241594
 impl std::ops::Deref for Connection {
gar/src/x11/events.rsmodified
@@ -54,11 +54,106 @@ impl WindowManager {
5454
             }
5555
         }
5656
 
57
+        // Grab Alt+Button1/Button3 on root for floating window move/resize
58
+        self.conn.grab_mod_buttons()?;
59
+
5760
         self.conn.flush()?;
5861
         tracing::info!("{} keybinds registered", state.keybinds.len());
5962
         Ok(())
6063
     }
6164
 
65
+    /// Set up EWMH workspace hints for status bar integration.
66
+    pub fn setup_ewmh_hints(&self) -> Result<()> {
67
+        // Advertise supported EWMH atoms
68
+        self.conn.set_ewmh_supported()?;
69
+
70
+        // Set number of desktops
71
+        let num_desktops = self.workspaces.len() as u32;
72
+        self.conn.set_number_of_desktops(num_desktops)?;
73
+
74
+        // Set desktop names
75
+        let names: Vec<String> = self.workspaces.iter().map(|ws| ws.name.clone()).collect();
76
+        self.conn.set_desktop_names(&names)?;
77
+
78
+        // Set current desktop
79
+        self.conn.set_current_desktop(self.focused_workspace as u32)?;
80
+
81
+        // Set active window (none at startup)
82
+        self.conn.set_active_window(None)?;
83
+
84
+        self.conn.flush()?;
85
+        tracing::info!("EWMH workspace hints initialized: {} desktops", num_desktops);
86
+        Ok(())
87
+    }
88
+
89
+    /// Adopt any existing windows that were already mapped before we started.
90
+    pub fn adopt_existing_windows(&mut self) -> Result<()> {
91
+        use x11rb::protocol::xproto::MapState;
92
+
93
+        // Query children of root window
94
+        let reply = self.conn.conn.query_tree(self.conn.root)?.reply()?;
95
+        let mut adopted = 0;
96
+
97
+        for &window in &reply.children {
98
+            // Get window attributes to check if it's mapped and not override-redirect
99
+            let attrs = match self.conn.conn.get_window_attributes(window)?.reply() {
100
+                Ok(a) => a,
101
+                Err(_) => continue, // Window may have been destroyed
102
+            };
103
+
104
+            // Skip override-redirect windows (menus, tooltips, etc.)
105
+            if attrs.override_redirect {
106
+                continue;
107
+            }
108
+
109
+            // Only adopt currently mapped windows
110
+            if attrs.map_state != MapState::VIEWABLE {
111
+                continue;
112
+            }
113
+
114
+            // Skip if we already manage this window
115
+            if self.windows.contains_key(&window) {
116
+                continue;
117
+            }
118
+
119
+            // Subscribe to events on the window
120
+            self.conn.select_input(
121
+                window,
122
+                EventMask::ENTER_WINDOW
123
+                    | EventMask::FOCUS_CHANGE
124
+                    | EventMask::PROPERTY_CHANGE
125
+                    | EventMask::STRUCTURE_NOTIFY,
126
+            )?;
127
+
128
+            // Grab button for click-to-focus
129
+            self.conn.grab_button(window)?;
130
+
131
+            // Check window rules and EWMH hints
132
+            let rule_actions = self.check_rules(window);
133
+            let should_float = rule_actions.floating.unwrap_or_else(|| self.conn.should_float(window));
134
+
135
+            // Manage the window
136
+            if should_float {
137
+                self.manage_window_floating(window);
138
+            } else {
139
+                self.manage_window(window);
140
+            }
141
+            adopted += 1;
142
+        }
143
+
144
+        if adopted > 0 {
145
+            self.apply_layout()?;
146
+            // Focus the first window
147
+            if let Some(window) = self.focused_window {
148
+                self.set_focus(window)?;
149
+                self.conn.ungrab_button(window)?;
150
+            }
151
+            tracing::info!("Adopted {} existing windows", adopted);
152
+        }
153
+
154
+        Ok(())
155
+    }
156
+
62157
     pub fn handle_event(&mut self, event: Event) -> Result<()> {
63158
         match event {
64159
             Event::MapRequest(e) => self.handle_map_request(e)?,
@@ -103,17 +198,43 @@ impl WindowManager {
103198
         // Grab button for click-to-focus
104199
         self.conn.grab_button(window)?;
105200
 
106
-        // Manage the window
107
-        self.manage_window(window);
201
+        // Check window rules first
202
+        let rule_actions = self.check_rules(window);
203
+
204
+        // Determine target workspace (rule or current)
205
+        let target_workspace = rule_actions.workspace.unwrap_or(self.focused_workspace + 1);
206
+        let target_idx = target_workspace.saturating_sub(1).min(self.workspaces.len() - 1);
108207
 
109
-        // Map the window
110
-        self.conn.map_window(window)?;
208
+        // Determine if window should float (rule > ICCCM/EWMH hints)
209
+        let should_float = rule_actions.floating.unwrap_or_else(|| self.conn.should_float(window));
210
+
211
+        // Manage window on target workspace
212
+        if target_idx != self.focused_workspace {
213
+            // Window goes to a different workspace
214
+            if should_float {
215
+                self.manage_window_floating_on_workspace(window, target_idx);
216
+            } else {
217
+                self.manage_window_on_workspace(window, target_idx);
218
+            }
219
+            // Don't map - it's on another workspace
220
+        } else {
221
+            // Window goes to current workspace
222
+            if should_float {
223
+                self.manage_window_floating(window);
224
+            } else {
225
+                self.manage_window(window);
226
+            }
227
+            // Map the window
228
+            self.conn.map_window(window)?;
229
+        }
111230
 
112231
         // Apply layout to all windows
113232
         self.apply_layout()?;
114233
 
115
-        // Focus the new window
116
-        self.set_focus(window)?;
234
+        // Focus the new window (only if on current workspace)
235
+        if target_idx == self.focused_workspace {
236
+            self.set_focus(window)?;
237
+        }
117238
 
118239
         Ok(())
119240
     }
@@ -178,38 +299,46 @@ impl WindowManager {
178299
 
179300
     fn handle_button_press(&mut self, event: ButtonPressEvent) -> Result<()> {
180301
         let window = event.event;
181
-        tracing::debug!("ButtonPress on window {}, button {}", window, event.detail);
302
+        let child = event.child;
303
+        tracing::debug!("ButtonPress on window {}, child {}, button {}", window, child, event.detail);
182304
 
183305
         // Check for mod+click on floating windows (move/resize)
184306
         let has_mod = event.state.contains(x11rb::protocol::xproto::KeyButMask::MOD1);
185307
 
186
-        if has_mod && self.is_floating(window) {
187
-            let geometry = self.get_floating_geometry(window);
308
+        // For Alt+click from root grab, use child window (the window under cursor)
309
+        let target = if window == self.conn.root && child != 0 {
310
+            child
311
+        } else {
312
+            window
313
+        };
314
+
315
+        if has_mod && self.is_floating(target) {
316
+            let geometry = self.get_floating_geometry(target);
188317
 
189318
             if event.detail == 1 {
190319
                 // Mod+Button1 = Move
191
-                tracing::debug!("Starting move for floating window {}", window);
320
+                tracing::debug!("Starting move for floating window {}", target);
192321
                 self.drag_state = Some(DragState::Move {
193
-                    window,
322
+                    window: target,
194323
                     start_x: event.root_x,
195324
                     start_y: event.root_y,
196325
                     start_geometry: geometry,
197326
                 });
198327
                 // Grab pointer for motion events
199
-                self.conn.grab_pointer(window)?;
328
+                self.conn.grab_pointer(target)?;
200329
                 return Ok(());
201330
             } else if event.detail == 3 {
202331
                 // Mod+Button3 = Resize
203332
                 let edge = determine_resize_edge(&geometry, event.root_x, event.root_y);
204
-                tracing::debug!("Starting resize for floating window {}, edge {:?}", window, edge);
333
+                tracing::debug!("Starting resize for floating window {}, edge {:?}", target, edge);
205334
                 self.drag_state = Some(DragState::Resize {
206
-                    window,
335
+                    window: target,
207336
                     start_x: event.root_x,
208337
                     start_y: event.root_y,
209338
                     start_geometry: geometry,
210339
                     edge,
211340
                 });
212
-                self.conn.grab_pointer(window)?;
341
+                self.conn.grab_pointer(target)?;
213342
                 return Ok(());
214343
             }
215344
         }
@@ -385,6 +514,9 @@ impl WindowManager {
385514
                     self.toggle_floating(window)?;
386515
                 }
387516
             }
517
+            Action::CycleFloating => {
518
+                self.cycle_floating()?;
519
+            }
388520
             Action::LuaCallback(index) => {
389521
                 if let Err(e) = self.lua_config.execute_callback(index) {
390522
                     tracing::error!("Lua callback error: {}", e);
@@ -397,11 +529,16 @@ impl WindowManager {
397529
     fn close_window(&mut self, window: u32) -> Result<()> {
398530
         tracing::info!("Closing window {}", window);
399531
 
400
-        // TODO: Send WM_DELETE_WINDOW if supported (ICCCM)
401
-        // For now, just kill the client
402
-        self.conn.conn.kill_client(window)?;
403
-        self.conn.sync()?;
532
+        // Try graceful ICCCM close first
533
+        if self.conn.supports_delete_window(window) {
534
+            tracing::debug!("Window {} supports WM_DELETE_WINDOW, sending graceful close", window);
535
+            self.conn.send_delete_window(window)?;
536
+        } else {
537
+            tracing::debug!("Window {} doesn't support WM_DELETE_WINDOW, using kill_client", window);
538
+            self.conn.conn.kill_client(window)?;
539
+        }
404540
 
541
+        self.conn.flush()?;
405542
         Ok(())
406543
     }
407544
 
@@ -482,26 +619,30 @@ impl WindowManager {
482619
 
483620
         tracing::info!("Switching to workspace {}", idx + 1);
484621
 
485
-        // Hide windows on current workspace
486
-        for window in self.current_workspace().tree.windows() {
622
+        // Hide all windows on current workspace (tiled + floating)
623
+        for window in self.current_workspace().all_windows() {
487624
             self.conn.unmap_window(window)?;
488625
         }
489626
 
490627
         // Switch workspace
491628
         self.focused_workspace = idx;
492629
 
493
-        // Show windows on new workspace
494
-        for window in self.current_workspace().tree.windows() {
630
+        // Update EWMH _NET_CURRENT_DESKTOP
631
+        self.conn.set_current_desktop(idx as u32)?;
632
+
633
+        // Show all windows on new workspace (tiled + floating)
634
+        for window in self.current_workspace().all_windows() {
495635
             self.conn.map_window(window)?;
496636
         }
497637
 
498638
         // Apply layout and update focus
499639
         self.apply_layout()?;
500640
 
501
-        // Focus the workspace's focused window or first window
641
+        // Focus the workspace's focused window, preferring floating on top
502642
         if let Some(window) = self
503643
             .current_workspace()
504644
             .focused
645
+            .or_else(|| self.current_workspace().floating.last().copied())
505646
             .or_else(|| self.current_workspace().tree.first_window())
506647
         {
507648
             self.set_focus(window)?;
@@ -523,13 +664,20 @@ impl WindowManager {
523664
             return Ok(());
524665
         };
525666
 
526
-        tracing::info!("Moving window {} to workspace {}", window, idx + 1);
667
+        let is_floating = self.windows.get(&window).map(|w| w.floating).unwrap_or(false);
668
+
669
+        tracing::info!("Moving window {} to workspace {} (floating: {})", window, idx + 1, is_floating);
527670
 
528
-        // Remove from current workspace tree
529
-        self.current_workspace_mut().tree.remove(window);
671
+        // Remove from current workspace (tree or floating list)
672
+        if is_floating {
673
+            self.current_workspace_mut().remove_floating(window);
674
+        } else {
675
+            self.current_workspace_mut().tree.remove(window);
676
+        }
530677
 
531678
         // Update focus on current workspace
532
-        self.focused_window = self.current_workspace().tree.first_window();
679
+        self.focused_window = self.current_workspace().tree.first_window()
680
+            .or_else(|| self.current_workspace().floating.last().copied());
533681
         self.current_workspace_mut().focused = self.focused_window;
534682
 
535683
         // Update window's workspace tracking
@@ -537,15 +685,22 @@ impl WindowManager {
537685
             win.workspace = idx;
538686
         }
539687
 
688
+        // Update EWMH _NET_WM_DESKTOP
689
+        self.conn.set_window_desktop(window, idx as u32)?;
690
+
540691
         // Hide the window (it's moving to another workspace)
541692
         self.conn.unmap_window(window)?;
542693
 
543694
         // Insert into target workspace
544
-        let target_focused = self.workspaces[idx].focused;
545
-        let screen = self.screen_rect();
546
-        self.workspaces[idx]
547
-            .tree
548
-            .insert_with_rect(window, target_focused, screen);
695
+        if is_floating {
696
+            self.workspaces[idx].add_floating(window);
697
+        } else {
698
+            let target_focused = self.workspaces[idx].focused;
699
+            let screen = self.screen_rect();
700
+            self.workspaces[idx]
701
+                .tree
702
+                .insert_with_rect(window, target_focused, screen);
703
+        }
549704
 
550705
         // Re-apply layout on current workspace
551706
         self.apply_layout()?;
@@ -595,6 +750,12 @@ impl WindowManager {
595750
         // Set up keybinds
596751
         self.setup_grabs()?;
597752
 
753
+        // Set up EWMH workspace hints
754
+        self.setup_ewmh_hints()?;
755
+
756
+        // Adopt any existing windows
757
+        self.adopt_existing_windows()?;
758
+
598759
         while self.running {
599760
             let event = self.conn.conn.wait_for_event()?;
600761
             self.handle_event(event)?;
@@ -616,20 +777,20 @@ impl WindowManager {
616777
     fn get_floating_geometry(&self, window: u32) -> Rect {
617778
         self.windows
618779
             .get(&window)
619
-            .map(|w| w.geometry)
780
+            .map(|w| w.floating_geometry)
620781
             .unwrap_or_default()
621782
     }
622783
 
623784
     fn set_floating_position(&mut self, window: u32, x: i16, y: i16) -> Result<()> {
624785
         if let Some(win) = self.windows.get_mut(&window) {
625
-            win.geometry.x = x;
626
-            win.geometry.y = y;
786
+            win.floating_geometry.x = x;
787
+            win.floating_geometry.y = y;
627788
             self.conn.configure_window(
628789
                 window,
629790
                 x,
630791
                 y,
631
-                win.geometry.width,
632
-                win.geometry.height,
792
+                win.floating_geometry.width,
793
+                win.floating_geometry.height,
633794
                 self.config.border_width,
634795
             )?;
635796
             self.conn.flush()?;
@@ -639,7 +800,7 @@ impl WindowManager {
639800
 
640801
     fn set_floating_geometry(&mut self, window: u32, x: i16, y: i16, w: u16, h: u16) -> Result<()> {
641802
         if let Some(win) = self.windows.get_mut(&window) {
642
-            win.geometry = Rect::new(x, y, w, h);
803
+            win.floating_geometry = Rect::new(x, y, w, h);
643804
             self.conn.configure_window(
644805
                 window,
645806
                 x,
@@ -654,33 +815,84 @@ impl WindowManager {
654815
     }
655816
 
656817
     fn raise_window(&mut self, window: u32) -> Result<()> {
818
+        // Update stacking order in workspace's floating list
819
+        self.current_workspace_mut().raise_floating(window);
820
+
821
+        // Raise in X11
657822
         let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
658823
         self.conn.conn.configure_window(window, &aux)?;
659824
         self.conn.flush()?;
660825
         Ok(())
661826
     }
662827
 
828
+    /// Cycle through floating windows on the current workspace.
829
+    fn cycle_floating(&mut self) -> Result<()> {
830
+        let floating = &self.current_workspace().floating;
831
+        if floating.is_empty() {
832
+            tracing::debug!("No floating windows to cycle");
833
+            return Ok(());
834
+        }
835
+
836
+        // Find current position in floating list
837
+        let current_idx = self.focused_window
838
+            .and_then(|w| floating.iter().position(|&fw| fw == w));
839
+
840
+        // Get next floating window (wrap around)
841
+        let next_idx = match current_idx {
842
+            Some(idx) => (idx + 1) % floating.len(),
843
+            None => 0, // Not focused on a floating window, focus the first one
844
+        };
845
+
846
+        let next_window = floating[next_idx];
847
+
848
+        // Regrab button on old focused window
849
+        if let Some(old) = self.focused_window {
850
+            self.conn.grab_button(old)?;
851
+        }
852
+
853
+        // Focus and raise the next floating window
854
+        self.set_focus(next_window)?;
855
+        self.conn.ungrab_button(next_window)?;
856
+        self.raise_window(next_window)?;
857
+
858
+        tracing::debug!("Cycled to floating window {} (idx {})", next_window, next_idx);
859
+        Ok(())
860
+    }
861
+
663862
     fn toggle_floating(&mut self, window: u32) -> Result<()> {
664
-        let Some(win) = self.windows.get_mut(&window) else {
863
+        // Check if window is managed
864
+        let Some(win_state) = self.windows.get(&window) else {
865
+            tracing::warn!("toggle_floating: window {} not managed", window);
665866
             return Ok(());
666867
         };
868
+        let is_floating = win_state.floating;
667869
 
668
-        if win.floating {
870
+        tracing::debug!(
871
+            "toggle_floating: window={}, is_floating={}, in_tree={}, in_floating_list={}",
872
+            window,
873
+            is_floating,
874
+            self.current_workspace().tree.contains(window),
875
+            self.current_workspace().floating.contains(&window)
876
+        );
877
+
878
+        if is_floating {
669879
             // Return to tiled
670880
             tracing::info!("Returning window {} to tiled", window);
671
-            win.floating = false;
881
+
882
+            // Update window state
883
+            if let Some(win) = self.windows.get_mut(&window) {
884
+                win.floating = false;
885
+            }
672886
 
673887
             // Remove from floating list
674
-            self.current_workspace_mut()
675
-                .floating
676
-                .retain(|w| w.id != window);
888
+            self.current_workspace_mut().remove_floating(window);
677889
 
678
-            // Insert back into BSP tree
679
-            let focused = self.current_workspace().focused;
890
+            // Find a target window to insert next to (not ourselves)
891
+            let target = self.current_workspace().tree.first_window();
680892
             let screen = self.screen_rect();
681893
             self.current_workspace_mut()
682894
                 .tree
683
-                .insert_with_rect(window, focused, screen);
895
+                .insert_with_rect(window, target, screen);
684896
 
685897
             // Re-apply layout
686898
             self.apply_layout()?;
@@ -688,44 +900,37 @@ impl WindowManager {
688900
             // Make floating
689901
             tracing::info!("Floating window {}", window);
690902
 
691
-            // Get current geometry before removing from tree
903
+            // Check if window is actually in the tree
904
+            if !self.current_workspace().tree.contains(window) {
905
+                tracing::warn!("toggle_floating: window {} not in tree, cannot float", window);
906
+                return Ok(());
907
+            }
908
+
909
+            // Use a centered floating geometry (80% of screen size, centered)
692910
             let screen = self.screen_rect();
693
-            let geometries = self.current_workspace().tree.calculate_geometries(screen);
694
-            let geometry = geometries
695
-                .iter()
696
-                .find(|(w, _)| *w == window)
697
-                .map(|(_, r)| *r)
698
-                .unwrap_or_else(|| Rect::new(100, 100, 640, 480));
911
+            let float_w = (screen.width * 4 / 5).max(400);
912
+            let float_h = (screen.height * 4 / 5).max(300);
913
+            let float_x = screen.x + (screen.width as i16 - float_w as i16) / 2;
914
+            let float_y = screen.y + (screen.height as i16 - float_h as i16) / 2;
915
+            let geometry = Rect::new(float_x, float_y, float_w, float_h);
916
+
917
+            tracing::debug!("Floating geometry: {:?}", geometry);
699918
 
700919
             // Remove from BSP tree
701
-            self.current_workspace_mut().tree.remove(window);
920
+            let removed = self.current_workspace_mut().tree.remove(window);
921
+            tracing::debug!("Removed from tree: {}", removed);
702922
 
703
-            // Mark as floating and store geometry
704
-            let win = self.windows.get_mut(&window).unwrap();
705
-            win.floating = true;
706
-            win.geometry = geometry;
923
+            // Update window state with floating geometry
924
+            if let Some(win) = self.windows.get_mut(&window) {
925
+                win.floating = true;
926
+                win.floating_geometry = geometry;
927
+            }
707928
 
708
-            // Add to floating list
709
-            let workspace_idx = self.focused_workspace;
710
-            self.current_workspace_mut()
711
-                .floating
712
-                .push(crate::core::Window::new(window, workspace_idx));
929
+            // Add to floating list (on top)
930
+            self.current_workspace_mut().add_floating(window);
713931
 
714
-            // Re-apply layout for tiled windows
932
+            // Re-apply layout (this will configure the floating window and stack it)
715933
             self.apply_layout()?;
716
-
717
-            // Configure floating window to its geometry
718
-            self.conn.configure_window(
719
-                window,
720
-                geometry.x,
721
-                geometry.y,
722
-                geometry.width,
723
-                geometry.height,
724
-                self.config.border_width,
725
-            )?;
726
-
727
-            // Raise floating window above tiled
728
-            self.raise_window(window)?;
729934
         }
730935
 
731936
         Ok(())