gardesk/gar / 79cf08a

Browse files

Sprint 8: EWMH/ICCCM, fullscreen, urgency, titlebars, i3-style workspaces

- EWMH: _NET_SUPPORTING_WM_CHECK, _NET_CLIENT_LIST, ClientMessage handler
- ICCCM: WM_HINTS and WM_NORMAL_HINTS parsing
- Fullscreen: toggle support, Mod+Shift+f keybind
- Urgency: PropertyNotify handler, border_color_urgent, clear on focus
- Title bars: frame.rs reparenting framework, config options
- i3-style: dynamic workspace/monitor assignment, follow_window_on_move
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
79cf08a65627854e3d4de428f099d40df95adc77
Parents
0c71d2c
Tree
368e870

8 changed files

StatusFile+-
M gar/src/config/lua.rs 58 0
M gar/src/config/mod.rs 18 0
M gar/src/core/mod.rs 316 58
M gar/src/core/window.rs 19 0
M gar/src/x11/connection.rs 272 1
M gar/src/x11/events.rs 332 54
A gar/src/x11/frame.rs 290 0
M gar/src/x11/mod.rs 2 0
gar/src/config/lua.rsmodified
@@ -22,6 +22,7 @@ pub enum Action {
2222
     Reload,
2323
     Exit,
2424
     ToggleFloating,
25
+    ToggleFullscreen,
2526
     CycleFloating,
2627
     FocusMonitor(String),     // "next", "prev", or monitor name
2728
     MoveToMonitor(String),    // "next", "prev", or monitor name
@@ -220,6 +221,15 @@ impl LuaConfig {
220221
                         }
221222
                     }
222223
                 }
224
+                "border_color_urgent" => {
225
+                    if let Value::String(s) = value {
226
+                        if let Ok(str_val) = s.to_str() {
227
+                            if let Some(color) = parse_color(&str_val) {
228
+                                state.config.border_color_urgent = color;
229
+                            }
230
+                        }
231
+                    }
232
+                }
223233
                 "gap_inner" => {
224234
                     if let Value::Integer(v) = value {
225235
                         state.config.gap_inner = v as u32;
@@ -230,6 +240,48 @@ impl LuaConfig {
230240
                         state.config.gap_outer = v as u32;
231241
                     }
232242
                 }
243
+                "titlebar_enabled" => {
244
+                    if let Value::Boolean(v) = value {
245
+                        state.config.titlebar_enabled = v;
246
+                    }
247
+                }
248
+                "titlebar_height" => {
249
+                    if let Value::Integer(v) = value {
250
+                        state.config.titlebar_height = v as u32;
251
+                    }
252
+                }
253
+                "titlebar_color_focused" => {
254
+                    if let Value::String(s) = value {
255
+                        if let Ok(str_val) = s.to_str() {
256
+                            if let Some(color) = parse_color(&str_val) {
257
+                                state.config.titlebar_color_focused = color;
258
+                            }
259
+                        }
260
+                    }
261
+                }
262
+                "titlebar_color_unfocused" => {
263
+                    if let Value::String(s) = value {
264
+                        if let Ok(str_val) = s.to_str() {
265
+                            if let Some(color) = parse_color(&str_val) {
266
+                                state.config.titlebar_color_unfocused = color;
267
+                            }
268
+                        }
269
+                    }
270
+                }
271
+                "titlebar_text_color" => {
272
+                    if let Value::String(s) = value {
273
+                        if let Ok(str_val) = s.to_str() {
274
+                            if let Some(color) = parse_color(&str_val) {
275
+                                state.config.titlebar_text_color = color;
276
+                            }
277
+                        }
278
+                    }
279
+                }
280
+                "follow_window_on_move" => {
281
+                    if let Value::Boolean(v) = value {
282
+                        state.config.follow_window_on_move = v;
283
+                    }
284
+                }
233285
                 _ => {
234286
                     tracing::warn!("Unknown config key: {}", key);
235287
                 }
@@ -271,6 +323,7 @@ impl LuaConfig {
271323
                             "exit" => Action::Exit,
272324
                             "equalize" => Action::Equalize,
273325
                             "toggle_floating" => Action::ToggleFloating,
326
+                            "toggle_fullscreen" => Action::ToggleFullscreen,
274327
                             "cycle_floating" => Action::CycleFloating,
275328
                             "focus" => {
276329
                                 let dir: String = t.get("direction").unwrap_or_default();
@@ -426,6 +479,11 @@ impl LuaConfig {
426479
         toggle_floating.set("action", "toggle_floating")?;
427480
         gar.set("toggle_floating", toggle_floating)?;
428481
 
482
+        // gar.toggle_fullscreen
483
+        let toggle_fullscreen = self.lua.create_table()?;
484
+        toggle_fullscreen.set("action", "toggle_fullscreen")?;
485
+        gar.set("toggle_fullscreen", toggle_fullscreen)?;
486
+
429487
         // gar.cycle_floating
430488
         let cycle_floating = self.lua.create_table()?;
431489
         cycle_floating.set("action", "cycle_floating")?;
gar/src/config/mod.rsmodified
@@ -7,8 +7,17 @@ pub struct Config {
77
     pub border_width: u32,
88
     pub border_color_focused: u32,
99
     pub border_color_unfocused: u32,
10
+    pub border_color_urgent: u32,
1011
     pub gap_inner: u32,
1112
     pub gap_outer: u32,
13
+    // Title bar settings
14
+    pub titlebar_enabled: bool,
15
+    pub titlebar_height: u32,
16
+    pub titlebar_color_focused: u32,
17
+    pub titlebar_color_unfocused: u32,
18
+    pub titlebar_text_color: u32,
19
+    // Behavior settings
20
+    pub follow_window_on_move: bool,
1221
 }
1322
 
1423
 impl Default for Config {
@@ -17,8 +26,17 @@ impl Default for Config {
1726
             border_width: 2,
1827
             border_color_focused: 0x5294e2,
1928
             border_color_unfocused: 0x2d2d2d,
29
+            border_color_urgent: 0xff5555, // Red for urgent windows
2030
             gap_inner: 0,
2131
             gap_outer: 0,
32
+            // Title bars disabled by default
33
+            titlebar_enabled: false,
34
+            titlebar_height: 20,
35
+            titlebar_color_focused: 0x3d3d3d,
36
+            titlebar_color_unfocused: 0x2d2d2d,
37
+            titlebar_text_color: 0xffffff,
38
+            // Behavior: follow window when moving to another workspace
39
+            follow_window_on_move: false,
2240
         }
2341
     }
2442
 }
gar/src/core/mod.rsmodified
@@ -16,6 +16,7 @@ use crate::config::{Config, LuaConfig, LuaState, RuleActions, WindowMatch};
1616
 use crate::ipc::IpcServer;
1717
 use crate::x11::Connection;
1818
 use crate::x11::events::DragState;
19
+use crate::x11::FrameManager;
1920
 use crate::Result;
2021
 
2122
 pub struct WindowManager {
@@ -34,6 +35,8 @@ pub struct WindowManager {
3435
     pub ipc_server: Option<IpcServer>,
3536
     /// Timestamp of last pointer warp - used to suppress EnterNotify feedback loop
3637
     pub last_warp: std::time::Instant,
38
+    /// Frame manager for title bars
39
+    pub frames: FrameManager,
3740
 }
3841
 
3942
 impl WindowManager {
@@ -87,16 +90,12 @@ impl WindowManager {
8790
             ));
8891
         }
8992
 
90
-        // Assign workspaces to monitors
91
-        let ws_count = workspaces.len();
92
-        let mon_count = monitors.len();
93
+        // i3-style: each monitor starts with one workspace (1, 2, 3...)
94
+        // Any workspace can be moved to any monitor dynamically
9395
         for (i, monitor) in monitors.iter_mut().enumerate() {
94
-            // Distribute workspaces: first monitor gets ws 1-N/M, etc.
95
-            let start = i * ws_count / mon_count;
96
-            let end = (i + 1) * ws_count / mon_count;
97
-            monitor.workspaces = (start..end).collect();
98
-            monitor.active_workspace = start;
99
-            tracing::debug!("Monitor '{}' assigned workspaces {:?}", monitor.name, monitor.workspaces);
96
+            monitor.workspaces = vec![i]; // Just track initial workspace
97
+            monitor.active_workspace = i; // Monitor 0 shows ws 0, monitor 1 shows ws 1, etc.
98
+            tracing::debug!("Monitor '{}' starts with workspace {}", monitor.name, i + 1);
10099
         }
101100
 
102101
         Ok(Self {
@@ -114,6 +113,7 @@ impl WindowManager {
114113
             drag_state: None,
115114
             ipc_server,
116115
             last_warp: std::time::Instant::now(),
116
+            frames: FrameManager::new(),
117117
         })
118118
     }
119119
 
@@ -141,14 +141,14 @@ impl WindowManager {
141141
             .unwrap_or_else(|| Rect::new(0, 0, self.conn.screen_width, self.conn.screen_height))
142142
     }
143143
 
144
-    /// Find which monitor a workspace belongs to.
144
+    /// Find which monitor is currently displaying a workspace (i3-style).
145145
     pub fn monitor_for_workspace(&self, workspace_idx: usize) -> Option<&Monitor> {
146
-        self.monitors.iter().find(|m| m.workspaces.contains(&workspace_idx))
146
+        self.monitors.iter().find(|m| m.active_workspace == workspace_idx)
147147
     }
148148
 
149
-    /// Find the monitor index for a workspace.
149
+    /// Find the monitor index currently displaying a workspace (i3-style).
150150
     pub fn monitor_idx_for_workspace(&self, workspace_idx: usize) -> Option<usize> {
151
-        self.monitors.iter().position(|m| m.workspaces.contains(&workspace_idx))
151
+        self.monitors.iter().position(|m| m.active_workspace == workspace_idx)
152152
     }
153153
 
154154
     /// Refresh monitors (called on RandR screen change).
@@ -168,15 +168,27 @@ impl WindowManager {
168168
             ));
169169
         }
170170
 
171
-        // Reassign workspaces to monitors
172
-        let ws_count = self.workspaces.len();
173
-        let mon_count = new_monitors.len();
171
+        // Try to preserve workspace assignments from old monitors
172
+        // If we have more monitors now, new ones get next available workspaces
173
+        let old_mon_count = self.monitors.len();
174
+        let mut used_workspaces: std::collections::HashSet<usize> = std::collections::HashSet::new();
175
+
174176
         for (i, monitor) in new_monitors.iter_mut().enumerate() {
175
-            let start = i * ws_count / mon_count;
176
-            let end = (i + 1) * ws_count / mon_count;
177
-            monitor.workspaces = (start..end).collect();
178
-            monitor.active_workspace = start;
179
-            tracing::info!("Monitor '{}' assigned workspaces {:?}", monitor.name, monitor.workspaces);
177
+            if i < old_mon_count {
178
+                // Preserve old monitor's workspace
179
+                monitor.active_workspace = self.monitors[i].active_workspace;
180
+                monitor.workspaces = vec![monitor.active_workspace];
181
+                used_workspaces.insert(monitor.active_workspace);
182
+            } else {
183
+                // New monitor - assign first unused workspace
184
+                let first_free = (0..self.workspaces.len())
185
+                    .find(|ws| !used_workspaces.contains(ws))
186
+                    .unwrap_or(0);
187
+                monitor.active_workspace = first_free;
188
+                monitor.workspaces = vec![first_free];
189
+                used_workspaces.insert(first_free);
190
+            }
191
+            tracing::info!("Monitor '{}' showing workspace {}", monitor.name, monitor.active_workspace + 1);
180192
         }
181193
 
182194
         self.monitors = new_monitors;
@@ -186,10 +198,8 @@ impl WindowManager {
186198
             self.focused_monitor = 0;
187199
         }
188200
 
189
-        // Ensure focused_workspace is on the focused monitor
190
-        if !self.monitors[self.focused_monitor].workspaces.contains(&self.focused_workspace) {
191
-            self.focused_workspace = self.monitors[self.focused_monitor].active_workspace;
192
-        }
201
+        // Update focused_workspace to match the focused monitor
202
+        self.focused_workspace = self.monitors[self.focused_monitor].active_workspace;
193203
 
194204
         // Re-apply layout for visible workspaces
195205
         self.apply_layout()?;
@@ -233,6 +243,9 @@ impl WindowManager {
233243
             .insert_with_rect(window, focused, screen);
234244
         self.current_workspace_mut().focused = Some(window);
235245
         self.focused_window = Some(window);
246
+
247
+        // Update EWMH client lists
248
+        self.update_client_lists();
236249
     }
237250
 
238251
     /// Add a window to management on a specific workspace.
@@ -254,6 +267,9 @@ impl WindowManager {
254267
         let focused = self.workspaces[workspace_idx].focused;
255268
         let screen = self.screen_rect();
256269
         self.workspaces[workspace_idx].tree.insert_with_rect(window, focused, screen);
270
+
271
+        // Update EWMH client lists
272
+        self.update_client_lists();
257273
     }
258274
 
259275
     /// Add a window to management as a floating window on a specific workspace.
@@ -282,6 +298,9 @@ impl WindowManager {
282298
 
283299
         // Add to target workspace's floating list
284300
         self.workspaces[workspace_idx].add_floating(window);
301
+
302
+        // Update EWMH client lists
303
+        self.update_client_lists();
285304
     }
286305
 
287306
     /// Add a window to management as a floating window.
@@ -312,6 +331,50 @@ impl WindowManager {
312331
         self.current_workspace_mut().add_floating(window);
313332
         self.current_workspace_mut().focused = Some(window);
314333
         self.focused_window = Some(window);
334
+
335
+        // Update EWMH client lists
336
+        self.update_client_lists();
337
+    }
338
+
339
+    /// Create a frame for a window if title bars are enabled.
340
+    /// Returns the frame window ID if created.
341
+    pub fn create_frame_for_window(&mut self, window: XWindow) -> Option<XWindow> {
342
+        if !self.config.titlebar_enabled {
343
+            return None;
344
+        }
345
+
346
+        // Get window title for display
347
+        let title = self.conn.get_window_title(window).unwrap_or_default();
348
+
349
+        // Create frame with initial geometry (will be updated by apply_layout)
350
+        let screen = self.screen_rect();
351
+        let frame = match self.frames.create_frame(
352
+            &self.conn.conn,
353
+            self.conn.root,
354
+            window,
355
+            screen.x,
356
+            screen.y,
357
+            400, // Initial width, will be adjusted
358
+            300, // Initial height, will be adjusted
359
+            self.config.titlebar_height as u16,
360
+            self.config.border_width as u16,
361
+            self.config.border_color_unfocused,
362
+            self.config.titlebar_color_unfocused,
363
+        ) {
364
+            Ok(f) => f,
365
+            Err(e) => {
366
+                tracing::error!("Failed to create frame for window {}: {}", window, e);
367
+                return None;
368
+            }
369
+        };
370
+
371
+        // Update window state with frame and title
372
+        if let Some(win) = self.windows.get_mut(&window) {
373
+            win.frame = Some(frame);
374
+            win.title = title;
375
+        }
376
+
377
+        Some(frame)
315378
     }
316379
 
317380
     /// Remove a window from management.
@@ -320,6 +383,13 @@ impl WindowManager {
320383
             let ws_idx = win.workspace;
321384
             tracing::info!("Unmanaging window {} from workspace {}", window, ws_idx + 1);
322385
 
386
+            // Destroy frame if it exists
387
+            if win.frame.is_some() {
388
+                if let Err(e) = self.frames.destroy_frame(&self.conn.conn, self.conn.root, window) {
389
+                    tracing::warn!("Failed to destroy frame for window {}: {}", window, e);
390
+                }
391
+            }
392
+
323393
             // Remove from the window's actual workspace (not current_workspace!)
324394
             if win.floating {
325395
                 self.workspaces[ws_idx].remove_floating(window);
@@ -334,6 +404,22 @@ impl WindowManager {
334404
                     .or_else(|| self.workspaces[ws_idx].floating.last().copied());
335405
                 self.workspaces[ws_idx].focused = self.focused_window;
336406
             }
407
+
408
+            // Update EWMH client lists
409
+            self.update_client_lists();
410
+        }
411
+    }
412
+
413
+    /// Update _NET_CLIENT_LIST and _NET_CLIENT_LIST_STACKING on root window.
414
+    pub fn update_client_lists(&self) {
415
+        let windows: Vec<u32> = self.windows.keys().copied().collect();
416
+        if let Err(e) = self.conn.update_client_list(&windows) {
417
+            tracing::warn!("Failed to update client list: {}", e);
418
+        }
419
+        // For stacking order, we use the same list for now (tiling WM doesn't have true stacking)
420
+        // A more sophisticated implementation would order by focus history
421
+        if let Err(e) = self.conn.update_client_list_stacking(&windows) {
422
+            tracing::warn!("Failed to update client list stacking: {}", e);
337423
         }
338424
     }
339425
 
@@ -371,6 +457,15 @@ impl WindowManager {
371457
     pub fn set_focus(&mut self, window: XWindow) -> Result<()> {
372458
         self.focused_window = Some(window);
373459
         self.current_workspace_mut().focused = Some(window);
460
+
461
+        // Clear urgency when window receives focus
462
+        if let Some(win) = self.windows.get_mut(&window) {
463
+            if win.urgent {
464
+                tracing::debug!("Clearing urgency for window {} on focus", window);
465
+                win.urgent = false;
466
+            }
467
+        }
468
+
374469
         self.conn.set_focus(window)?;
375470
         self.conn.set_active_window(Some(window))?;
376471
         self.update_borders()?;
@@ -385,6 +480,69 @@ impl WindowManager {
385480
         Ok(())
386481
     }
387482
 
483
+    /// Toggle fullscreen state for a window.
484
+    pub fn toggle_fullscreen(&mut self, window: XWindow) -> Result<()> {
485
+        let win = match self.windows.get_mut(&window) {
486
+            Some(w) => w,
487
+            None => return Ok(()),
488
+        };
489
+
490
+        let ws_idx = win.workspace;
491
+
492
+        if win.fullscreen {
493
+            // Exit fullscreen - restore previous state
494
+            tracing::info!("Window {} exiting fullscreen", window);
495
+
496
+            win.fullscreen = false;
497
+
498
+            // Restore previous floating state
499
+            let was_floating = win.pre_fullscreen_floating;
500
+            if was_floating != win.floating {
501
+                if was_floating {
502
+                    // Was floating before - remove from tree, add to floating
503
+                    self.workspaces[ws_idx].tree.remove(window);
504
+                    self.workspaces[ws_idx].add_floating(window);
505
+                } else {
506
+                    // Was tiled before - remove from floating, add to tree
507
+                    self.workspaces[ws_idx].remove_floating(window);
508
+                    let focused = self.workspaces[ws_idx].focused;
509
+                    let screen = self.screen_rect();
510
+                    self.workspaces[ws_idx].tree.insert_with_rect(window, focused, screen);
511
+                }
512
+                if let Some(w) = self.windows.get_mut(&window) {
513
+                    w.floating = was_floating;
514
+                }
515
+            }
516
+
517
+            // Clear EWMH fullscreen state
518
+            let _ = self.conn.set_window_state(window, &[]);
519
+        } else {
520
+            // Enter fullscreen
521
+            tracing::info!("Window {} entering fullscreen", window);
522
+
523
+            // Save current state
524
+            win.pre_fullscreen_floating = win.floating;
525
+            win.fullscreen = true;
526
+
527
+            // Set EWMH fullscreen state
528
+            let _ = self.conn.set_window_state(window, &[self.conn.net_wm_state_fullscreen]);
529
+        }
530
+
531
+        // Re-apply layout (fullscreen windows get special treatment in apply_layout)
532
+        self.apply_layout()?;
533
+        self.conn.flush()?;
534
+        Ok(())
535
+    }
536
+
537
+    /// Set fullscreen state for a window explicitly (for EWMH client messages).
538
+    pub fn set_fullscreen(&mut self, window: XWindow, fullscreen: bool) -> Result<()> {
539
+        let is_fullscreen = self.windows.get(&window).map(|w| w.fullscreen).unwrap_or(false);
540
+        if is_fullscreen != fullscreen {
541
+            self.toggle_fullscreen(window)?;
542
+        }
543
+        Ok(())
544
+    }
545
+
388546
     /// Warp pointer to center of a monitor (for focus without windows)
389547
     pub fn warp_to_monitor(&mut self, monitor_idx: usize) -> Result<()> {
390548
         let geom = self.monitors[monitor_idx].geometry;
@@ -403,11 +561,12 @@ impl WindowManager {
403561
         Ok(())
404562
     }
405563
 
406
-    /// Update border colors for all visible windows based on focus state.
564
+    /// Update border colors for all visible windows based on focus and urgency state.
407565
     pub fn update_borders(&mut self) -> Result<()> {
408566
         let focused = self.focused_window;
409567
         let focused_color = self.config.border_color_focused;
410568
         let unfocused_color = self.config.border_color_unfocused;
569
+        let urgent_color = self.config.border_color_urgent;
411570
         let border_width = self.config.border_width;
412571
 
413572
         // Get all visible workspace indices
@@ -416,12 +575,25 @@ impl WindowManager {
416575
         // Update borders for all windows on visible workspaces
417576
         for ws_idx in visible_ws {
418577
             for window in self.workspaces[ws_idx].all_windows() {
419
-                let color = if Some(window) == focused {
578
+                // Check if window is urgent (and not focused - focused clears urgency)
579
+                let is_urgent = self.windows.get(&window)
580
+                    .map(|w| w.urgent && Some(window) != focused)
581
+                    .unwrap_or(false);
582
+
583
+                let color = if is_urgent {
584
+                    urgent_color
585
+                } else if Some(window) == focused {
420586
                     focused_color
421587
                 } else {
422588
                     unfocused_color
423589
                 };
424
-                self.conn.set_border(window, border_width, color)?;
590
+
591
+                // If window has a frame, set border on the frame instead
592
+                if self.windows.get(&window).and_then(|w| w.frame).is_some() {
593
+                    self.frames.set_frame_border(&self.conn.conn, window, color)?;
594
+                } else {
595
+                    self.conn.set_border(window, border_width, color)?;
596
+                }
425597
             }
426598
         }
427599
         Ok(())
@@ -446,6 +618,37 @@ impl WindowManager {
446618
 
447619
         // Layout each monitor's active workspace
448620
         for (ws_idx, screen) in &visible_workspaces {
621
+            // Check for fullscreen windows on this workspace
622
+            let fullscreen_windows: Vec<XWindow> = self.windows.iter()
623
+                .filter(|(_, w)| w.workspace == *ws_idx && w.fullscreen)
624
+                .map(|(id, _)| *id)
625
+                .collect();
626
+
627
+            // If there's a fullscreen window, it takes the whole monitor
628
+            if let Some(&fs_window) = fullscreen_windows.first() {
629
+                tracing::debug!(
630
+                    "apply_layout: FULLSCREEN window={} on monitor {:?}",
631
+                    fs_window, screen
632
+                );
633
+
634
+                // Configure fullscreen window to cover entire monitor (no gaps, no borders)
635
+                self.conn.configure_window(
636
+                    fs_window,
637
+                    screen.x,
638
+                    screen.y,
639
+                    screen.width,
640
+                    screen.height,
641
+                    0, // No border for fullscreen
642
+                )?;
643
+
644
+                // Raise fullscreen window above everything
645
+                let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
646
+                self.conn.conn.configure_window(fs_window, &aux)?;
647
+
648
+                // Skip normal layout for this workspace - fullscreen window covers everything
649
+                continue;
650
+            }
651
+
449652
             let work_area = Rect::new(
450653
                 screen.x + gap_outer,
451654
                 screen.y + gap_outer,
@@ -460,6 +663,10 @@ impl WindowManager {
460663
                 ws.tree.window_count(), ws.floating.len()
461664
             );
462665
 
666
+            // Get titlebar settings
667
+            let titlebar_enabled = self.config.titlebar_enabled;
668
+            let titlebar_height = self.config.titlebar_height as u16;
669
+
463670
             // 1. Configure tiled windows from the BSP tree
464671
             let geometries = ws.tree.calculate_geometries(work_area);
465672
             for (window, rect) in &geometries {
@@ -473,19 +680,42 @@ impl WindowManager {
473680
                 let final_width = gapped_width.saturating_sub(2 * border_width as u16);
474681
                 let final_height = gapped_height.saturating_sub(2 * border_width as u16);
475682
 
476
-                tracing::debug!(
477
-                    "apply_layout: TILED window={} at ({}, {}) size {}x{}",
478
-                    window, gapped_x, gapped_y, final_width.max(1), final_height.max(1)
479
-                );
683
+                // Check if window has a frame
684
+                let has_frame = self.windows.get(window).and_then(|w| w.frame).is_some();
685
+
686
+                if has_frame && titlebar_enabled {
687
+                    // Configure frame (includes titlebar height)
688
+                    let client_height = final_height.saturating_sub(titlebar_height);
689
+                    self.frames.configure_frame(
690
+                        &self.conn.conn,
691
+                        *window,
692
+                        gapped_x,
693
+                        gapped_y,
694
+                        final_width.max(1),
695
+                        client_height.max(1),
696
+                        titlebar_height,
697
+                        border_width as u16,
698
+                    )?;
480699
 
481
-                self.conn.configure_window(
482
-                    *window,
483
-                    gapped_x,
484
-                    gapped_y,
485
-                    final_width.max(1),
486
-                    final_height.max(1),
487
-                    border_width,
488
-                )?;
700
+                    tracing::debug!(
701
+                        "apply_layout: TILED+FRAME window={} at ({}, {}) size {}x{} (titlebar: {})",
702
+                        window, gapped_x, gapped_y, final_width.max(1), final_height.max(1), titlebar_height
703
+                    );
704
+                } else {
705
+                    tracing::debug!(
706
+                        "apply_layout: TILED window={} at ({}, {}) size {}x{}",
707
+                        window, gapped_x, gapped_y, final_width.max(1), final_height.max(1)
708
+                    );
709
+
710
+                    self.conn.configure_window(
711
+                        *window,
712
+                        gapped_x,
713
+                        gapped_y,
714
+                        final_width.max(1),
715
+                        final_height.max(1),
716
+                        border_width,
717
+                    )?;
718
+                }
489719
             }
490720
 
491721
             // 2. Configure floating windows and stack them above tiled
@@ -498,24 +728,52 @@ impl WindowManager {
498728
                     let adjusted_width = geom.width.saturating_sub(2 * border_width as u16);
499729
                     let adjusted_height = geom.height.saturating_sub(2 * border_width as u16);
500730
 
501
-                    tracing::debug!(
502
-                        "apply_layout: FLOATING window={} at ({}, {}) size {}x{} (raising)",
503
-                        window_id, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1)
504
-                    );
505
-
506
-                    // Configure geometry
507
-                    self.conn.configure_window(
508
-                        window_id,
509
-                        geom.x,
510
-                        geom.y,
511
-                        adjusted_width.max(1),
512
-                        adjusted_height.max(1),
513
-                        border_width,
514
-                    )?;
515
-
516
-                    // Raise to top of stack (each subsequent window goes above the previous)
517
-                    let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
518
-                    self.conn.conn.configure_window(window_id, &aux)?;
731
+                    let has_frame = win.frame.is_some();
732
+
733
+                    if has_frame && titlebar_enabled {
734
+                        // Configure frame for floating window
735
+                        let client_height = adjusted_height.saturating_sub(titlebar_height);
736
+                        self.frames.configure_frame(
737
+                            &self.conn.conn,
738
+                            window_id,
739
+                            geom.x,
740
+                            geom.y,
741
+                            adjusted_width.max(1),
742
+                            client_height.max(1),
743
+                            titlebar_height,
744
+                            border_width as u16,
745
+                        )?;
746
+
747
+                        // Raise frame to top of stack
748
+                        if let Some(frame) = self.frames.frame_for_client(window_id) {
749
+                            let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
750
+                            self.conn.conn.configure_window(frame, &aux)?;
751
+                        }
752
+
753
+                        tracing::debug!(
754
+                            "apply_layout: FLOATING+FRAME window={} at ({}, {}) size {}x{} (raising)",
755
+                            window_id, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1)
756
+                        );
757
+                    } else {
758
+                        tracing::debug!(
759
+                            "apply_layout: FLOATING window={} at ({}, {}) size {}x{} (raising)",
760
+                            window_id, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1)
761
+                        );
762
+
763
+                        // Configure geometry
764
+                        self.conn.configure_window(
765
+                            window_id,
766
+                            geom.x,
767
+                            geom.y,
768
+                            adjusted_width.max(1),
769
+                            adjusted_height.max(1),
770
+                            border_width,
771
+                        )?;
772
+
773
+                        // Raise to top of stack (each subsequent window goes above the previous)
774
+                        let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
775
+                        self.conn.conn.configure_window(window_id, &aux)?;
776
+                    }
519777
                 } else {
520778
                     tracing::warn!("apply_layout: floating window {} not in windows map!", window_id);
521779
                 }
gar/src/core/window.rsmodified
@@ -10,8 +10,17 @@ pub struct Window {
1010
     pub mapped: bool,
1111
     pub focused: bool,
1212
     pub floating: bool,
13
+    pub fullscreen: bool,
14
+    /// Saved geometry before entering fullscreen (for restore)
15
+    pub pre_fullscreen_geometry: Option<Rect>,
16
+    /// Was the window floating before entering fullscreen?
17
+    pub pre_fullscreen_floating: bool,
1318
     pub urgent: bool,
1419
     pub workspace: usize,
20
+    /// Frame window ID (if title bars are enabled, client is reparented into this)
21
+    pub frame: Option<XWindow>,
22
+    /// Window title (cached from _NET_WM_NAME or WM_NAME)
23
+    pub title: String,
1524
 }
1625
 
1726
 impl Window {
@@ -22,8 +31,18 @@ impl Window {
2231
             mapped: false,
2332
             focused: false,
2433
             floating: false,
34
+            fullscreen: false,
35
+            pre_fullscreen_geometry: None,
36
+            pre_fullscreen_floating: false,
2537
             urgent: false,
2638
             workspace,
39
+            frame: None,
40
+            title: String::new(),
2741
         }
2842
     }
43
+
44
+    /// Get the window to configure (frame if present, otherwise client).
45
+    pub fn outer_window(&self) -> XWindow {
46
+        self.frame.unwrap_or(self.id)
47
+    }
2948
 }
gar/src/x11/connection.rsmodified
@@ -10,6 +10,27 @@ use x11rb::CURRENT_TIME;
1010
 
1111
 use super::Error;
1212
 
13
+/// Parsed WM_HINTS structure (ICCCM).
14
+#[derive(Debug, Clone, Default)]
15
+pub struct WmHints {
16
+    pub input: bool,
17
+    pub initial_state: Option<u32>,
18
+    pub urgent: bool,
19
+}
20
+
21
+/// Parsed WM_NORMAL_HINTS (size hints) structure (ICCCM).
22
+#[derive(Debug, Clone, Default)]
23
+pub struct SizeHints {
24
+    pub min_width: Option<u32>,
25
+    pub min_height: Option<u32>,
26
+    pub max_width: Option<u32>,
27
+    pub max_height: Option<u32>,
28
+    pub base_width: Option<u32>,
29
+    pub base_height: Option<u32>,
30
+    pub width_inc: Option<u32>,
31
+    pub height_inc: Option<u32>,
32
+}
33
+
1334
 pub struct Connection {
1435
     pub conn: RustConnection,
1536
     pub screen_num: usize,
@@ -20,6 +41,8 @@ pub struct Connection {
2041
     pub wm_protocols: Atom,
2142
     pub wm_delete_window: Atom,
2243
     pub wm_transient_for: Atom,
44
+    pub wm_hints: Atom,
45
+    pub wm_normal_hints: Atom,
2346
     // EWMH atoms for window types
2447
     pub net_wm_window_type: Atom,
2548
     pub net_wm_window_type_dialog: Atom,
@@ -30,14 +53,22 @@ pub struct Connection {
3053
     // EWMH atoms for window state
3154
     pub net_wm_state: Atom,
3255
     pub net_wm_state_modal: Atom,
56
+    pub net_wm_state_fullscreen: Atom,
3357
     // EWMH atoms for workspaces
3458
     pub net_supported: Atom,
59
+    pub net_supporting_wm_check: Atom,
60
+    pub net_client_list: Atom,
61
+    pub net_client_list_stacking: Atom,
62
+    pub net_close_window: Atom,
63
+    pub net_wm_name: Atom,
3564
     pub net_number_of_desktops: Atom,
3665
     pub net_current_desktop: Atom,
3766
     pub net_desktop_names: Atom,
3867
     pub net_wm_desktop: Atom,
3968
     pub net_active_window: Atom,
4069
     pub utf8_string: Atom,
70
+    // Compositor integration
71
+    pub net_wm_bypass_compositor: Atom,
4172
 }
4273
 
4374
 impl Connection {
@@ -58,6 +89,8 @@ impl Connection {
5889
         let wm_protocols = conn.intern_atom(false, b"WM_PROTOCOLS")?.reply()?.atom;
5990
         let wm_delete_window = conn.intern_atom(false, b"WM_DELETE_WINDOW")?.reply()?.atom;
6091
         let wm_transient_for = conn.intern_atom(false, b"WM_TRANSIENT_FOR")?.reply()?.atom;
92
+        let wm_hints = conn.intern_atom(false, b"WM_HINTS")?.reply()?.atom;
93
+        let wm_normal_hints = conn.intern_atom(false, b"WM_NORMAL_HINTS")?.reply()?.atom;
6194
 
6295
         // Intern EWMH atoms for window types
6396
         let net_wm_window_type = conn.intern_atom(false, b"_NET_WM_WINDOW_TYPE")?.reply()?.atom;
@@ -70,9 +103,15 @@ impl Connection {
70103
         // Intern EWMH atoms for window state
71104
         let net_wm_state = conn.intern_atom(false, b"_NET_WM_STATE")?.reply()?.atom;
72105
         let net_wm_state_modal = conn.intern_atom(false, b"_NET_WM_STATE_MODAL")?.reply()?.atom;
106
+        let net_wm_state_fullscreen = conn.intern_atom(false, b"_NET_WM_STATE_FULLSCREEN")?.reply()?.atom;
73107
 
74
-        // Intern EWMH atoms for workspaces
108
+        // Intern EWMH atoms for workspaces and WM identification
75109
         let net_supported = conn.intern_atom(false, b"_NET_SUPPORTED")?.reply()?.atom;
110
+        let net_supporting_wm_check = conn.intern_atom(false, b"_NET_SUPPORTING_WM_CHECK")?.reply()?.atom;
111
+        let net_client_list = conn.intern_atom(false, b"_NET_CLIENT_LIST")?.reply()?.atom;
112
+        let net_client_list_stacking = conn.intern_atom(false, b"_NET_CLIENT_LIST_STACKING")?.reply()?.atom;
113
+        let net_close_window = conn.intern_atom(false, b"_NET_CLOSE_WINDOW")?.reply()?.atom;
114
+        let net_wm_name = conn.intern_atom(false, b"_NET_WM_NAME")?.reply()?.atom;
76115
         let net_number_of_desktops = conn.intern_atom(false, b"_NET_NUMBER_OF_DESKTOPS")?.reply()?.atom;
77116
         let net_current_desktop = conn.intern_atom(false, b"_NET_CURRENT_DESKTOP")?.reply()?.atom;
78117
         let net_desktop_names = conn.intern_atom(false, b"_NET_DESKTOP_NAMES")?.reply()?.atom;
@@ -80,6 +119,9 @@ impl Connection {
80119
         let net_active_window = conn.intern_atom(false, b"_NET_ACTIVE_WINDOW")?.reply()?.atom;
81120
         let utf8_string = conn.intern_atom(false, b"UTF8_STRING")?.reply()?.atom;
82121
 
122
+        // Compositor integration atom - apps can set this to request un-redirection for fullscreen
123
+        let net_wm_bypass_compositor = conn.intern_atom(false, b"_NET_WM_BYPASS_COMPOSITOR")?.reply()?.atom;
124
+
83125
         tracing::info!(
84126
             "Connected to X server, screen {}x{}",
85127
             screen_width,
@@ -95,6 +137,8 @@ impl Connection {
95137
             wm_protocols,
96138
             wm_delete_window,
97139
             wm_transient_for,
140
+            wm_hints,
141
+            wm_normal_hints,
98142
             net_wm_window_type,
99143
             net_wm_window_type_dialog,
100144
             net_wm_window_type_utility,
@@ -103,13 +147,20 @@ impl Connection {
103147
             net_wm_window_type_notification,
104148
             net_wm_state,
105149
             net_wm_state_modal,
150
+            net_wm_state_fullscreen,
106151
             net_supported,
152
+            net_supporting_wm_check,
153
+            net_client_list,
154
+            net_client_list_stacking,
155
+            net_close_window,
156
+            net_wm_name,
107157
             net_number_of_desktops,
108158
             net_current_desktop,
109159
             net_desktop_names,
110160
             net_wm_desktop,
111161
             net_active_window,
112162
             utf8_string,
163
+            net_wm_bypass_compositor,
113164
         })
114165
     }
115166
 
@@ -267,6 +318,19 @@ impl Connection {
267318
         Ok(())
268319
     }
269320
 
321
+    /// Warp the mouse pointer to absolute screen coordinates.
322
+    pub fn warp_pointer(&self, x: i16, y: i16) -> Result<(), Error> {
323
+        self.conn.warp_pointer(
324
+            x11rb::NONE,  // src_window
325
+            self.root,    // dst_window (root for absolute coords)
326
+            0, 0,         // src_x, src_y
327
+            0, 0,         // src_width, src_height
328
+            x,            // dst_x
329
+            y,            // dst_y
330
+        )?;
331
+        Ok(())
332
+    }
333
+
270334
     /// Set window border width and color.
271335
     pub fn set_border(&self, window: Window, width: u32, color: u32) -> Result<(), Error> {
272336
         let aux = ChangeWindowAttributesAux::new().border_pixel(color);
@@ -544,15 +608,137 @@ impl Connection {
544608
         None
545609
     }
546610
 
611
+    /// Get WM_HINTS for a window (ICCCM).
612
+    /// Returns urgency flag and other hints.
613
+    pub fn get_wm_hints(&self, window: Window) -> Option<WmHints> {
614
+        let reply = self.conn.get_property(
615
+            false,
616
+            window,
617
+            self.wm_hints,
618
+            AtomEnum::ANY,
619
+            0,
620
+            9, // WM_HINTS has 9 32-bit values
621
+        ).ok()?.reply().ok()?;
622
+
623
+        if reply.format != 32 || reply.value.len() < 4 {
624
+            return None;
625
+        }
626
+
627
+        // Parse the 32-bit values
628
+        let values: Vec<u32> = reply.value
629
+            .chunks_exact(4)
630
+            .map(|chunk| u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
631
+            .collect();
632
+
633
+        if values.is_empty() {
634
+            return None;
635
+        }
636
+
637
+        let flags = values[0];
638
+
639
+        // Flag bits from ICCCM
640
+        const INPUT_HINT: u32 = 1 << 0;
641
+        const STATE_HINT: u32 = 1 << 1;
642
+        const URGENCY_HINT: u32 = 1 << 8; // XUrgencyHint
643
+
644
+        let mut hints = WmHints::default();
645
+
646
+        // Input hint (does window want keyboard focus?)
647
+        if flags & INPUT_HINT != 0 && values.len() > 1 {
648
+            hints.input = values[1] != 0;
649
+        } else {
650
+            hints.input = true; // Default to accepting input
651
+        }
652
+
653
+        // Initial state hint
654
+        if flags & STATE_HINT != 0 && values.len() > 2 {
655
+            hints.initial_state = Some(values[2]);
656
+        }
657
+
658
+        // Urgency hint
659
+        hints.urgent = flags & URGENCY_HINT != 0;
660
+
661
+        Some(hints)
662
+    }
663
+
664
+    /// Get WM_NORMAL_HINTS (size hints) for a window (ICCCM).
665
+    pub fn get_size_hints(&self, window: Window) -> Option<SizeHints> {
666
+        let reply = self.conn.get_property(
667
+            false,
668
+            window,
669
+            self.wm_normal_hints,
670
+            AtomEnum::ANY,
671
+            0,
672
+            18, // WM_SIZE_HINTS has up to 18 32-bit values
673
+        ).ok()?.reply().ok()?;
674
+
675
+        if reply.format != 32 || reply.value.len() < 4 {
676
+            return None;
677
+        }
678
+
679
+        // Parse the 32-bit values
680
+        let values: Vec<u32> = reply.value
681
+            .chunks_exact(4)
682
+            .map(|chunk| u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
683
+            .collect();
684
+
685
+        if values.is_empty() {
686
+            return None;
687
+        }
688
+
689
+        let flags = values[0];
690
+
691
+        // Flag bits from ICCCM (indices into the values array)
692
+        const P_MIN_SIZE: u32 = 1 << 4;    // min_width, min_height at [5], [6]
693
+        const P_MAX_SIZE: u32 = 1 << 5;    // max_width, max_height at [7], [8]
694
+        const P_RESIZE_INC: u32 = 1 << 6;  // width_inc, height_inc at [9], [10]
695
+        const P_BASE_SIZE: u32 = 1 << 8;   // base_width, base_height at [15], [16]
696
+
697
+        let mut hints = SizeHints::default();
698
+
699
+        // Min size (indices 5, 6)
700
+        if flags & P_MIN_SIZE != 0 && values.len() > 6 {
701
+            hints.min_width = Some(values[5]);
702
+            hints.min_height = Some(values[6]);
703
+        }
704
+
705
+        // Max size (indices 7, 8)
706
+        if flags & P_MAX_SIZE != 0 && values.len() > 8 {
707
+            hints.max_width = Some(values[7]);
708
+            hints.max_height = Some(values[8]);
709
+        }
710
+
711
+        // Resize increment (indices 9, 10)
712
+        if flags & P_RESIZE_INC != 0 && values.len() > 10 {
713
+            hints.width_inc = Some(values[9]);
714
+            hints.height_inc = Some(values[10]);
715
+        }
716
+
717
+        // Base size (indices 15, 16)
718
+        if flags & P_BASE_SIZE != 0 && values.len() > 16 {
719
+            hints.base_width = Some(values[15]);
720
+            hints.base_height = Some(values[16]);
721
+        }
722
+
723
+        Some(hints)
724
+    }
725
+
547726
     /// Set _NET_SUPPORTED on root window to advertise supported EWMH atoms.
548727
     pub fn set_ewmh_supported(&self) -> Result<(), Error> {
549728
         let supported = [
550729
             self.net_supported,
730
+            self.net_supporting_wm_check,
731
+            self.net_client_list,
732
+            self.net_client_list_stacking,
551733
             self.net_number_of_desktops,
552734
             self.net_current_desktop,
553735
             self.net_desktop_names,
554736
             self.net_wm_desktop,
555737
             self.net_active_window,
738
+            self.net_close_window,
739
+            self.net_wm_state,
740
+            self.net_wm_state_fullscreen,
741
+            self.net_wm_name,
556742
         ];
557743
         self.conn.change_property32(
558744
             x11rb::protocol::xproto::PropMode::REPLACE,
@@ -564,6 +750,79 @@ impl Connection {
564750
         Ok(())
565751
     }
566752
 
753
+    /// Setup _NET_SUPPORTING_WM_CHECK window and set _NET_WM_NAME.
754
+    /// Returns the check window ID for cleanup.
755
+    pub fn setup_wm_check(&self) -> Result<Window, Error> {
756
+        use x11rb::protocol::xproto::{CreateWindowAux, WindowClass, PropMode};
757
+
758
+        // Create a small off-screen window for WM identification
759
+        let check_window = self.conn.generate_id()?;
760
+        self.conn.create_window(
761
+            0, // depth: copy from parent
762
+            check_window,
763
+            self.root,
764
+            -1, -1, 1, 1, // x, y, width, height (off-screen)
765
+            0, // border_width
766
+            WindowClass::INPUT_OUTPUT,
767
+            0, // visual: copy from parent
768
+            &CreateWindowAux::new(),
769
+        )?;
770
+
771
+        // Set _NET_SUPPORTING_WM_CHECK on root window pointing to check window
772
+        self.conn.change_property32(
773
+            PropMode::REPLACE,
774
+            self.root,
775
+            self.net_supporting_wm_check,
776
+            AtomEnum::WINDOW,
777
+            &[check_window],
778
+        )?;
779
+
780
+        // Set _NET_SUPPORTING_WM_CHECK on check window pointing to itself
781
+        self.conn.change_property32(
782
+            PropMode::REPLACE,
783
+            check_window,
784
+            self.net_supporting_wm_check,
785
+            AtomEnum::WINDOW,
786
+            &[check_window],
787
+        )?;
788
+
789
+        // Set _NET_WM_NAME on check window to "gar"
790
+        self.conn.change_property8(
791
+            PropMode::REPLACE,
792
+            check_window,
793
+            self.net_wm_name,
794
+            self.utf8_string,
795
+            b"gar",
796
+        )?;
797
+
798
+        tracing::info!("Created WM check window {}", check_window);
799
+        Ok(check_window)
800
+    }
801
+
802
+    /// Update _NET_CLIENT_LIST on root window with all managed windows.
803
+    pub fn update_client_list(&self, windows: &[Window]) -> Result<(), Error> {
804
+        self.conn.change_property32(
805
+            x11rb::protocol::xproto::PropMode::REPLACE,
806
+            self.root,
807
+            self.net_client_list,
808
+            AtomEnum::WINDOW,
809
+            windows,
810
+        )?;
811
+        Ok(())
812
+    }
813
+
814
+    /// Update _NET_CLIENT_LIST_STACKING on root window with windows in stacking order.
815
+    pub fn update_client_list_stacking(&self, windows: &[Window]) -> Result<(), Error> {
816
+        self.conn.change_property32(
817
+            x11rb::protocol::xproto::PropMode::REPLACE,
818
+            self.root,
819
+            self.net_client_list_stacking,
820
+            AtomEnum::WINDOW,
821
+            windows,
822
+        )?;
823
+        Ok(())
824
+    }
825
+
567826
     /// Set _NET_NUMBER_OF_DESKTOPS on root window.
568827
     pub fn set_number_of_desktops(&self, count: u32) -> Result<(), Error> {
569828
         self.conn.change_property32(
@@ -631,6 +890,18 @@ impl Connection {
631890
         Ok(())
632891
     }
633892
 
893
+    /// Set _NET_WM_STATE on a window with the specified state atoms.
894
+    pub fn set_window_state(&self, window: Window, states: &[Atom]) -> Result<(), Error> {
895
+        self.conn.change_property32(
896
+            x11rb::protocol::xproto::PropMode::REPLACE,
897
+            window,
898
+            self.net_wm_state,
899
+            AtomEnum::ATOM,
900
+            states,
901
+        )?;
902
+        Ok(())
903
+    }
904
+
634905
     /// Detect connected monitors via RandR.
635906
     pub fn detect_monitors(&self) -> Result<Vec<crate::core::Monitor>, Error> {
636907
         use x11rb::protocol::randr::{self, ConnectionExt as RandrExt};
gar/src/x11/events.rsmodified
@@ -2,9 +2,10 @@ use std::process::Command;
22
 
33
 use x11rb::connection::Connection as X11Connection;
44
 use x11rb::protocol::xproto::{
5
-    ButtonPressEvent, ButtonReleaseEvent, ConfigureRequestEvent, ConfigureWindowAux, ConnectionExt,
6
-    DestroyNotifyEvent, EnterNotifyEvent, EventMask, KeyPressEvent, MapRequestEvent, ModMask,
7
-    MotionNotifyEvent, NotifyMode, StackMode, UnmapNotifyEvent,
5
+    ButtonPressEvent, ButtonReleaseEvent, ClientMessageEvent, ConfigureRequestEvent,
6
+    ConfigureWindowAux, ConnectionExt, DestroyNotifyEvent, EnterNotifyEvent, EventMask,
7
+    ExposeEvent, KeyPressEvent, MapRequestEvent, ModMask, MotionNotifyEvent, NotifyMode,
8
+    PropertyNotifyEvent, StackMode, UnmapNotifyEvent,
89
 };
910
 use x11rb::protocol::Event;
1011
 
@@ -67,6 +68,13 @@ impl WindowManager {
6768
         // Advertise supported EWMH atoms
6869
         self.conn.set_ewmh_supported()?;
6970
 
71
+        // Create WM check window for EWMH identification
72
+        self.conn.setup_wm_check()?;
73
+
74
+        // Initialize empty client lists
75
+        self.conn.update_client_list(&[])?;
76
+        self.conn.update_client_list_stacking(&[])?;
77
+
7078
         // Set number of desktops
7179
         let num_desktops = self.workspaces.len() as u32;
7280
         self.conn.set_number_of_desktops(num_desktops)?;
@@ -175,6 +183,15 @@ impl WindowManager {
175183
                 tracing::info!("RandR notify event, refreshing monitors");
176184
                 self.refresh_monitors()?;
177185
             }
186
+            Event::ClientMessage(e) => {
187
+                self.handle_client_message(e)?;
188
+            }
189
+            Event::PropertyNotify(e) => {
190
+                self.handle_property_notify(e)?;
191
+            }
192
+            Event::Expose(e) => {
193
+                self.handle_expose(e)?;
194
+            }
178195
             _ => {
179196
                 tracing::trace!("Unhandled event: {:?}", event);
180197
             }
@@ -224,6 +241,8 @@ impl WindowManager {
224241
             } else {
225242
                 self.manage_window_on_workspace(window, target_idx);
226243
             }
244
+            // Create frame if title bars enabled
245
+            self.create_frame_for_window(window);
227246
             // Don't map - it's on another workspace
228247
         } else {
229248
             // Window goes to current workspace
@@ -232,7 +251,13 @@ impl WindowManager {
232251
             } else {
233252
                 self.manage_window(window);
234253
             }
235
-            // Map the window
254
+            // Create frame if title bars enabled
255
+            let frame = self.create_frame_for_window(window);
256
+
257
+            // Map the window (and frame if present)
258
+            if frame.is_some() {
259
+                self.frames.map_frame(&self.conn.conn, window)?;
260
+            }
236261
             self.conn.map_window(window)?;
237262
         }
238263
 
@@ -502,6 +527,169 @@ impl WindowManager {
502527
         Ok(())
503528
     }
504529
 
530
+    /// Handle EWMH client message requests (focus, workspace switch, close, state changes).
531
+    fn handle_client_message(&mut self, event: ClientMessageEvent) -> Result<()> {
532
+        let msg_type = event.type_;
533
+        let window = event.window;
534
+
535
+        if msg_type == self.conn.net_active_window {
536
+            // Application requesting focus
537
+            tracing::debug!("ClientMessage: _NET_ACTIVE_WINDOW for window {}", window);
538
+
539
+            if self.windows.contains_key(&window) {
540
+                // Get the window's workspace and switch to it if needed
541
+                if let Some(win) = self.windows.get(&window) {
542
+                    let ws_idx = win.workspace;
543
+                    if ws_idx != self.focused_workspace {
544
+                        self.switch_workspace(ws_idx)?;
545
+                    }
546
+                }
547
+                // Focus the window
548
+                if let Some(old) = self.focused_window {
549
+                    self.conn.grab_button(old)?;
550
+                }
551
+                self.set_focus(window)?;
552
+                self.conn.ungrab_button(window)?;
553
+                if self.is_floating(window) {
554
+                    self.raise_window(window)?;
555
+                }
556
+            }
557
+        } else if msg_type == self.conn.net_current_desktop {
558
+            // Workspace switch request (from pagers, etc.)
559
+            let desktop = event.data.as_data32()[0] as usize;
560
+            tracing::debug!("ClientMessage: _NET_CURRENT_DESKTOP to {}", desktop);
561
+
562
+            if desktop < self.workspaces.len() {
563
+                self.switch_workspace(desktop)?;
564
+            }
565
+        } else if msg_type == self.conn.net_close_window {
566
+            // Close window request
567
+            tracing::debug!("ClientMessage: _NET_CLOSE_WINDOW for window {}", window);
568
+
569
+            if self.windows.contains_key(&window) {
570
+                self.close_window(window)?;
571
+            }
572
+        } else if msg_type == self.conn.net_wm_state {
573
+            // Window state change request (fullscreen, etc.)
574
+            let action = event.data.as_data32()[0];
575
+            let property = event.data.as_data32()[1];
576
+            tracing::debug!(
577
+                "ClientMessage: _NET_WM_STATE action={} property={} for window {}",
578
+                action, property, window
579
+            );
580
+
581
+            // Handle fullscreen state changes
582
+            if property == self.conn.net_wm_state_fullscreen {
583
+                // action: 0 = remove, 1 = add, 2 = toggle
584
+                match action {
585
+                    0 => {
586
+                        // Remove fullscreen
587
+                        self.set_fullscreen(window, false)?;
588
+                    }
589
+                    1 => {
590
+                        // Add fullscreen
591
+                        self.set_fullscreen(window, true)?;
592
+                    }
593
+                    2 => {
594
+                        // Toggle fullscreen
595
+                        self.toggle_fullscreen(window)?;
596
+                    }
597
+                    _ => {}
598
+                }
599
+            }
600
+        } else {
601
+            tracing::trace!("Unhandled ClientMessage type: {}", msg_type);
602
+        }
603
+
604
+        self.conn.flush()?;
605
+        Ok(())
606
+    }
607
+
608
+    /// Handle PropertyNotify events (urgency hints, etc.).
609
+    fn handle_property_notify(&mut self, event: PropertyNotifyEvent) -> Result<()> {
610
+        let window = event.window;
611
+        let atom = event.atom;
612
+
613
+        // Check if WM_HINTS changed (urgency flag may have changed)
614
+        if atom == self.conn.wm_hints {
615
+            // Only handle for managed windows
616
+            if !self.windows.contains_key(&window) {
617
+                return Ok(());
618
+            }
619
+
620
+            tracing::debug!("WM_HINTS changed for window {}", window);
621
+
622
+            // Get the current WM_HINTS
623
+            if let Some(hints) = self.conn.get_wm_hints(window) {
624
+                let old_urgent = self.windows.get(&window).map(|w| w.urgent).unwrap_or(false);
625
+                let new_urgent = hints.urgent;
626
+
627
+                if old_urgent != new_urgent {
628
+                    tracing::info!(
629
+                        "Window {} urgency changed: {} -> {}",
630
+                        window, old_urgent, new_urgent
631
+                    );
632
+
633
+                    // Update window state
634
+                    if let Some(win) = self.windows.get_mut(&window) {
635
+                        win.urgent = new_urgent;
636
+                    }
637
+
638
+                    // Update border colors
639
+                    self.update_borders()?;
640
+                    self.conn.flush()?;
641
+                }
642
+            }
643
+        }
644
+
645
+        Ok(())
646
+    }
647
+
648
+    /// Handle Expose events to redraw title bars.
649
+    fn handle_expose(&mut self, event: ExposeEvent) -> Result<()> {
650
+        let window = event.window;
651
+
652
+        // Only process when count is 0 (last expose in batch)
653
+        if event.count != 0 {
654
+            return Ok(());
655
+        }
656
+
657
+        // Check if this is a frame window
658
+        if let Some(client) = self.frames.client_for_frame(window) {
659
+            // Redraw the title bar
660
+            if self.config.titlebar_enabled {
661
+                let win_state = self.windows.get(&client);
662
+                let title = win_state.map(|w| w.title.as_str()).unwrap_or("");
663
+                let focused = self.focused_window == Some(client);
664
+
665
+                let bg_color = if focused {
666
+                    self.config.titlebar_color_focused
667
+                } else {
668
+                    self.config.titlebar_color_unfocused
669
+                };
670
+
671
+                // Get frame width from expose event
672
+                let width = event.width;
673
+                let titlebar_height = self.config.titlebar_height as u16;
674
+
675
+                self.frames.draw_titlebar(
676
+                    &self.conn.conn,
677
+                    client,
678
+                    title,
679
+                    width,
680
+                    titlebar_height,
681
+                    bg_color,
682
+                    self.config.titlebar_text_color,
683
+                    focused,
684
+                )?;
685
+
686
+                self.conn.flush()?;
687
+            }
688
+        }
689
+
690
+        Ok(())
691
+    }
692
+
505693
     fn handle_key_press(&mut self, event: KeyPressEvent) -> Result<()> {
506694
         let keycode = event.detail;
507695
         let state = event.state;
@@ -591,6 +779,11 @@ impl WindowManager {
591779
                     self.toggle_floating(window)?;
592780
                 }
593781
             }
782
+            Action::ToggleFullscreen => {
783
+                if let Some(window) = self.focused_window {
784
+                    self.toggle_fullscreen(window)?;
785
+                }
786
+            }
594787
             Action::CycleFloating => {
595788
                 self.cycle_floating()?;
596789
             }
@@ -766,60 +959,106 @@ impl WindowManager {
766959
         Ok(())
767960
     }
768961
 
962
+    /// Switch to workspace using i3-style behavior:
963
+    /// - If workspace is visible on another monitor, focus moves to that monitor
964
+    /// - If workspace is not visible, it appears on the current monitor
769965
     fn switch_workspace(&mut self, idx: usize) -> Result<()> {
770966
         if idx >= self.workspaces.len() {
771967
             return Ok(());
772968
         }
773969
 
774
-        // Find which monitor owns this workspace
775
-        let target_monitor_idx = self.monitor_idx_for_workspace(idx).unwrap_or(0);
776
-        let old_ws_on_target = self.monitors[target_monitor_idx].active_workspace;
970
+        // Check if workspace is already visible on some monitor
971
+        let visible_on_monitor = self.monitors.iter().position(|m| m.active_workspace == idx);
777972
 
778
-        // Already on this workspace?
779
-        if idx == old_ws_on_target && target_monitor_idx == self.focused_monitor {
780
-            return Ok(());
781
-        }
973
+        if let Some(monitor_idx) = visible_on_monitor {
974
+            // Workspace is already visible - just focus that monitor (i3 behavior)
975
+            if monitor_idx == self.focused_monitor {
976
+                // Already on this workspace on this monitor
977
+                return Ok(());
978
+            }
782979
 
783
-        tracing::info!(
784
-            "Switching to workspace {} on monitor {} (was ws {})",
785
-            idx + 1, target_monitor_idx, old_ws_on_target + 1
786
-        );
980
+            tracing::info!(
981
+                "Workspace {} already visible on monitor {}, focusing it",
982
+                idx + 1, monitor_idx
983
+            );
984
+
985
+            // Focus the monitor that has this workspace
986
+            self.focused_monitor = monitor_idx;
987
+            self.focused_workspace = idx;
988
+
989
+            // Update EWMH
990
+            self.conn.set_current_desktop(idx as u32)?;
991
+
992
+            // Warp pointer to that monitor
993
+            let monitor_geom = self.monitors[monitor_idx].geometry;
994
+            let center_x = monitor_geom.x + (monitor_geom.width as i16 / 2);
995
+            let center_y = monitor_geom.y + (monitor_geom.height as i16 / 2);
996
+            self.conn.warp_pointer(center_x, center_y)?;
997
+            self.last_warp = std::time::Instant::now();
998
+
999
+            // Focus a window on that workspace
1000
+            if let Some(window) = self.workspaces[idx].focused
1001
+                .or_else(|| self.workspaces[idx].floating.last().copied())
1002
+                .or_else(|| self.workspaces[idx].tree.first_window())
1003
+            {
1004
+                self.set_focus(window)?;
1005
+            } else {
1006
+                self.focused_window = None;
1007
+                self.conn.set_active_window(None)?;
1008
+            }
1009
+        } else {
1010
+            // Workspace not visible - show it on current monitor (i3 behavior)
1011
+            let current_monitor = self.focused_monitor;
1012
+            let old_ws = self.monitors[current_monitor].active_workspace;
1013
+
1014
+            tracing::info!(
1015
+                "Switching monitor {} from workspace {} to {}",
1016
+                current_monitor, old_ws + 1, idx + 1
1017
+            );
7871018
 
788
-        // If switching to a different workspace on the target monitor, hide old/show new
789
-        if idx != old_ws_on_target {
7901019
             // Hide windows on old workspace
791
-            for window in self.workspaces[old_ws_on_target].all_windows() {
1020
+            for window in self.workspaces[old_ws].all_windows() {
7921021
                 self.conn.unmap_window(window)?;
1022
+                // Also unmap frames if present
1023
+                if let Some(frame) = self.frames.frame_for_client(window) {
1024
+                    self.conn.unmap_window(frame)?;
1025
+                }
7931026
             }
7941027
 
7951028
             // Update monitor's active workspace
796
-            self.monitors[target_monitor_idx].active_workspace = idx;
1029
+            self.monitors[current_monitor].active_workspace = idx;
1030
+            self.focused_workspace = idx;
1031
+
1032
+            // Update EWMH
1033
+            self.conn.set_current_desktop(idx as u32)?;
7971034
 
7981035
             // Show windows on new workspace
7991036
             for window in self.workspaces[idx].all_windows() {
1037
+                if let Some(frame) = self.frames.frame_for_client(window) {
1038
+                    self.conn.map_window(frame)?;
1039
+                }
8001040
                 self.conn.map_window(window)?;
8011041
             }
802
-        }
8031042
 
804
-        // Update focused state
805
-        self.focused_monitor = target_monitor_idx;
806
-        self.focused_workspace = idx;
807
-
808
-        // Update EWMH _NET_CURRENT_DESKTOP
809
-        self.conn.set_current_desktop(idx as u32)?;
810
-
811
-        // Apply layout
812
-        self.apply_layout()?;
1043
+            // Apply layout
1044
+            self.apply_layout()?;
8131045
 
814
-        // Focus a window on the target workspace
815
-        if let Some(window) = self.workspaces[idx].focused
816
-            .or_else(|| self.workspaces[idx].floating.last().copied())
817
-            .or_else(|| self.workspaces[idx].tree.first_window())
818
-        {
819
-            self.set_focus(window)?;
820
-            self.conn.ungrab_button(window)?;
821
-        } else {
822
-            self.focused_window = None;
1046
+            // Focus a window on the new workspace
1047
+            if let Some(window) = self.workspaces[idx].focused
1048
+                .or_else(|| self.workspaces[idx].floating.last().copied())
1049
+                .or_else(|| self.workspaces[idx].tree.first_window())
1050
+            {
1051
+                self.set_focus(window)?;
1052
+            } else {
1053
+                // No windows - warp to center of monitor
1054
+                self.focused_window = None;
1055
+                self.conn.set_active_window(None)?;
1056
+                let monitor_geom = self.monitors[current_monitor].geometry;
1057
+                let center_x = monitor_geom.x + (monitor_geom.width as i16 / 2);
1058
+                let center_y = monitor_geom.y + (monitor_geom.height as i16 / 2);
1059
+                self.conn.warp_pointer(center_x, center_y)?;
1060
+                self.last_warp = std::time::Instant::now();
1061
+            }
8231062
         }
8241063
 
8251064
         self.conn.flush()?;
@@ -827,7 +1066,7 @@ impl WindowManager {
8271066
     }
8281067
 
8291068
     fn move_to_workspace(&mut self, idx: usize) -> Result<()> {
830
-        if idx >= self.workspaces.len() || idx == self.focused_workspace {
1069
+        if idx >= self.workspaces.len() {
8311070
             return Ok(());
8321071
         }
8331072
 
@@ -835,21 +1074,30 @@ impl WindowManager {
8351074
             return Ok(());
8361075
         };
8371076
 
1077
+        // Get current workspace for this window
1078
+        let current_ws = self.windows.get(&window).map(|w| w.workspace).unwrap_or(self.focused_workspace);
1079
+
1080
+        // Don't move if already on target workspace
1081
+        if idx == current_ws {
1082
+            return Ok(());
1083
+        }
1084
+
8381085
         let is_floating = self.windows.get(&window).map(|w| w.floating).unwrap_or(false);
8391086
 
840
-        tracing::info!("Moving window {} to workspace {} (floating: {})", window, idx + 1, is_floating);
1087
+        tracing::info!("Moving window {} from workspace {} to {} (floating: {})",
1088
+            window, current_ws + 1, idx + 1, is_floating);
8411089
 
8421090
         // Remove from current workspace (tree or floating list)
8431091
         if is_floating {
844
-            self.current_workspace_mut().remove_floating(window);
1092
+            self.workspaces[current_ws].remove_floating(window);
8451093
         } else {
846
-            self.current_workspace_mut().tree.remove(window);
1094
+            self.workspaces[current_ws].tree.remove(window);
8471095
         }
8481096
 
8491097
         // Update focus on current workspace
850
-        self.focused_window = self.current_workspace().tree.first_window()
851
-            .or_else(|| self.current_workspace().floating.last().copied());
852
-        self.current_workspace_mut().focused = self.focused_window;
1098
+        let new_focus_on_current = self.workspaces[current_ws].tree.first_window()
1099
+            .or_else(|| self.workspaces[current_ws].floating.last().copied());
1100
+        self.workspaces[current_ws].focused = new_focus_on_current;
8531101
 
8541102
         // Update window's workspace tracking
8551103
         if let Some(win) = self.windows.get_mut(&window) {
@@ -859,27 +1107,57 @@ impl WindowManager {
8591107
         // Update EWMH _NET_WM_DESKTOP
8601108
         self.conn.set_window_desktop(window, idx as u32)?;
8611109
 
862
-        // Hide the window (it's moving to another workspace)
863
-        self.conn.unmap_window(window)?;
1110
+        // Check if target workspace is visible on any monitor
1111
+        let target_visible_on = self.monitors.iter().position(|m| m.active_workspace == idx);
8641112
 
865
-        // Insert into target workspace (use target monitor's geometry)
1113
+        // Insert into target workspace
8661114
         if is_floating {
8671115
             self.workspaces[idx].add_floating(window);
8681116
         } else {
8691117
             let target_focused = self.workspaces[idx].focused;
870
-            let screen = self.workspace_rect(idx);
1118
+            // Use target monitor's geometry if visible, otherwise use current monitor's
1119
+            let screen = if let Some(mon_idx) = target_visible_on {
1120
+                self.monitors[mon_idx].geometry
1121
+            } else {
1122
+                self.monitors[self.focused_monitor].geometry
1123
+            };
8711124
             self.workspaces[idx]
8721125
                 .tree
8731126
                 .insert_with_rect(window, target_focused, screen);
8741127
         }
8751128
 
876
-        // Re-apply layout on current workspace
1129
+        // If target workspace is visible, map the window; otherwise hide it
1130
+        if target_visible_on.is_some() {
1131
+            // Target is visible - map the window
1132
+            if let Some(frame) = self.frames.frame_for_client(window) {
1133
+                self.conn.map_window(frame)?;
1134
+            }
1135
+            self.conn.map_window(window)?;
1136
+        } else {
1137
+            // Target is not visible - hide the window
1138
+            self.conn.unmap_window(window)?;
1139
+            if let Some(frame) = self.frames.frame_for_client(window) {
1140
+                self.conn.unmap_window(frame)?;
1141
+            }
1142
+        }
1143
+
1144
+        // Re-apply layout
8771145
         self.apply_layout()?;
8781146
 
879
-        // Update focus
880
-        if let Some(new_focus) = self.focused_window {
881
-            self.set_focus(new_focus)?;
882
-            self.conn.ungrab_button(new_focus)?;
1147
+        // Check if we should follow the window to the target workspace
1148
+        if self.config.follow_window_on_move {
1149
+            // Switch to target workspace (this will focus the moved window)
1150
+            self.switch_workspace(idx)?;
1151
+        } else {
1152
+            // Stay on current workspace, update focus to next window
1153
+            if current_ws == self.focused_workspace {
1154
+                self.focused_window = new_focus_on_current;
1155
+                if let Some(new_focus) = self.focused_window {
1156
+                    self.set_focus(new_focus)?;
1157
+                } else {
1158
+                    self.conn.set_active_window(None)?;
1159
+                }
1160
+            }
8831161
         }
8841162
 
8851163
         self.conn.flush()?;
gar/src/x11/frame.rsadded
@@ -0,0 +1,290 @@
1
+//! Frame window management for title bars.
2
+//!
3
+//! When title bars are enabled, client windows are reparented into frame windows.
4
+//! The frame handles the title bar drawing and the client sits below it.
5
+
6
+use std::collections::HashMap;
7
+
8
+use x11rb::connection::Connection as X11Connection;
9
+use x11rb::protocol::xproto::{
10
+    ChangeWindowAttributesAux, ConfigureWindowAux, ConnectionExt, CreateGCAux, CreateWindowAux,
11
+    EventMask, Gcontext, Rectangle, Window, WindowClass,
12
+};
13
+use x11rb::COPY_DEPTH_FROM_PARENT;
14
+
15
+use super::Error;
16
+
17
+/// Manages frame windows for title bars.
18
+pub struct FrameManager {
19
+    /// Map from client window to frame window
20
+    client_to_frame: HashMap<Window, Window>,
21
+    /// Map from frame window to client window
22
+    frame_to_client: HashMap<Window, Window>,
23
+    /// Graphics context for drawing (reused)
24
+    gc: Option<Gcontext>,
25
+}
26
+
27
+impl FrameManager {
28
+    pub fn new() -> Self {
29
+        Self {
30
+            client_to_frame: HashMap::new(),
31
+            frame_to_client: HashMap::new(),
32
+            gc: None,
33
+        }
34
+    }
35
+
36
+    /// Check if a window is a frame we created.
37
+    pub fn is_frame(&self, window: Window) -> bool {
38
+        self.frame_to_client.contains_key(&window)
39
+    }
40
+
41
+    /// Get the client window for a frame.
42
+    pub fn client_for_frame(&self, frame: Window) -> Option<Window> {
43
+        self.frame_to_client.get(&frame).copied()
44
+    }
45
+
46
+    /// Get the frame window for a client.
47
+    pub fn frame_for_client(&self, client: Window) -> Option<Window> {
48
+        self.client_to_frame.get(&client).copied()
49
+    }
50
+
51
+    /// Create a frame window for a client and reparent the client into it.
52
+    pub fn create_frame<C: X11Connection>(
53
+        &mut self,
54
+        conn: &C,
55
+        root: Window,
56
+        client: Window,
57
+        x: i16,
58
+        y: i16,
59
+        width: u16,
60
+        height: u16,
61
+        titlebar_height: u16,
62
+        border_width: u16,
63
+        border_color: u32,
64
+        bg_color: u32,
65
+    ) -> Result<Window, Error> {
66
+        // Total frame height includes title bar + client area
67
+        let frame_height = height + titlebar_height;
68
+
69
+        // Generate a new window ID for the frame
70
+        let frame = conn.generate_id()?;
71
+
72
+        // Create the frame window
73
+        let aux = CreateWindowAux::new()
74
+            .event_mask(
75
+                EventMask::SUBSTRUCTURE_REDIRECT
76
+                    | EventMask::SUBSTRUCTURE_NOTIFY
77
+                    | EventMask::BUTTON_PRESS
78
+                    | EventMask::BUTTON_RELEASE
79
+                    | EventMask::ENTER_WINDOW
80
+                    | EventMask::EXPOSURE,
81
+            )
82
+            .background_pixel(bg_color)
83
+            .border_pixel(border_color);
84
+
85
+        conn.create_window(
86
+            COPY_DEPTH_FROM_PARENT,
87
+            frame,
88
+            root,
89
+            x,
90
+            y,
91
+            width,
92
+            frame_height,
93
+            border_width,
94
+            WindowClass::INPUT_OUTPUT,
95
+            0, // CopyFromParent visual
96
+            &aux,
97
+        )?;
98
+
99
+        // Reparent the client window into the frame, below the title bar
100
+        conn.reparent_window(client, frame, 0, titlebar_height as i16)?;
101
+
102
+        // Configure the client to fill the frame width
103
+        let client_aux = ConfigureWindowAux::new()
104
+            .x(0)
105
+            .y(titlebar_height as i32)
106
+            .width(width as u32)
107
+            .height(height as u32)
108
+            .border_width(0); // No border on client inside frame
109
+        conn.configure_window(client, &client_aux)?;
110
+
111
+        // Track the mapping
112
+        self.client_to_frame.insert(client, frame);
113
+        self.frame_to_client.insert(frame, client);
114
+
115
+        tracing::debug!(
116
+            "Created frame {} for client {} at ({}, {}) size {}x{} (titlebar: {})",
117
+            frame, client, x, y, width, frame_height, titlebar_height
118
+        );
119
+
120
+        Ok(frame)
121
+    }
122
+
123
+    /// Destroy a frame and reparent the client back to root.
124
+    pub fn destroy_frame<C: X11Connection>(
125
+        &mut self,
126
+        conn: &C,
127
+        root: Window,
128
+        client: Window,
129
+    ) -> Result<(), Error> {
130
+        if let Some(frame) = self.client_to_frame.remove(&client) {
131
+            self.frame_to_client.remove(&frame);
132
+
133
+            // Reparent client back to root
134
+            conn.reparent_window(client, root, 0, 0)?;
135
+
136
+            // Destroy the frame window
137
+            conn.destroy_window(frame)?;
138
+
139
+            tracing::debug!("Destroyed frame {} for client {}", frame, client);
140
+        }
141
+        Ok(())
142
+    }
143
+
144
+    /// Configure a frame's geometry.
145
+    pub fn configure_frame<C: X11Connection>(
146
+        &self,
147
+        conn: &C,
148
+        client: Window,
149
+        x: i16,
150
+        y: i16,
151
+        width: u16,
152
+        height: u16,
153
+        titlebar_height: u16,
154
+        border_width: u16,
155
+    ) -> Result<(), Error> {
156
+        if let Some(&frame) = self.client_to_frame.get(&client) {
157
+            let frame_height = height + titlebar_height;
158
+
159
+            // Configure frame position and size
160
+            let frame_aux = ConfigureWindowAux::new()
161
+                .x(x as i32)
162
+                .y(y as i32)
163
+                .width(width as u32)
164
+                .height(frame_height as u32)
165
+                .border_width(border_width as u32);
166
+            conn.configure_window(frame, &frame_aux)?;
167
+
168
+            // Configure client within frame
169
+            let client_aux = ConfigureWindowAux::new()
170
+                .x(0)
171
+                .y(titlebar_height as i32)
172
+                .width(width as u32)
173
+                .height(height as u32);
174
+            conn.configure_window(client, &client_aux)?;
175
+        }
176
+        Ok(())
177
+    }
178
+
179
+    /// Draw a title bar on the frame.
180
+    pub fn draw_titlebar<C: X11Connection>(
181
+        &mut self,
182
+        conn: &C,
183
+        client: Window,
184
+        title: &str,
185
+        width: u16,
186
+        titlebar_height: u16,
187
+        bg_color: u32,
188
+        text_color: u32,
189
+        focused: bool,
190
+    ) -> Result<(), Error> {
191
+        let Some(&frame) = self.client_to_frame.get(&client) else {
192
+            return Ok(());
193
+        };
194
+
195
+        // Ensure we have a GC
196
+        let gc = match self.gc {
197
+            Some(gc) => gc,
198
+            None => {
199
+                let gc = conn.generate_id()?;
200
+                let aux = CreateGCAux::new()
201
+                    .foreground(text_color)
202
+                    .background(bg_color);
203
+                conn.create_gc(gc, frame, &aux)?;
204
+                self.gc = Some(gc);
205
+                gc
206
+            }
207
+        };
208
+
209
+        // Update GC colors
210
+        let gc_aux = ChangeWindowAttributesAux::new();
211
+        let _ = gc_aux; // We'll use ChangeGC instead
212
+
213
+        // Clear the title bar area with background color
214
+        let rect = Rectangle {
215
+            x: 0,
216
+            y: 0,
217
+            width,
218
+            height: titlebar_height,
219
+        };
220
+
221
+        // Set fill color and fill rectangle
222
+        conn.change_gc(gc, &x11rb::protocol::xproto::ChangeGCAux::new().foreground(bg_color))?;
223
+        conn.poly_fill_rectangle(frame, gc, &[rect])?;
224
+
225
+        // Draw the title text (simple, centered vertically)
226
+        if !title.is_empty() {
227
+            conn.change_gc(gc, &x11rb::protocol::xproto::ChangeGCAux::new().foreground(text_color))?;
228
+
229
+            // Draw text at (8, titlebar_height - 4) - simple positioning
230
+            // X11 core fonts are limited, but this gives basic text rendering
231
+            let text_y = titlebar_height.saturating_sub(4) as i16;
232
+            conn.image_text8(frame, gc, 8, text_y, title.as_bytes())?;
233
+        }
234
+
235
+        // Add a focus indicator - subtle line at bottom
236
+        if focused {
237
+            let indicator_rect = Rectangle {
238
+                x: 0,
239
+                y: (titlebar_height - 2) as i16,
240
+                width,
241
+                height: 2,
242
+            };
243
+            conn.change_gc(gc, &x11rb::protocol::xproto::ChangeGCAux::new().foreground(0x5294e2))?; // Blue accent
244
+            conn.poly_fill_rectangle(frame, gc, &[indicator_rect])?;
245
+        }
246
+
247
+        Ok(())
248
+    }
249
+
250
+    /// Update frame border color.
251
+    pub fn set_frame_border<C: X11Connection>(
252
+        &self,
253
+        conn: &C,
254
+        client: Window,
255
+        border_color: u32,
256
+    ) -> Result<(), Error> {
257
+        if let Some(&frame) = self.client_to_frame.get(&client) {
258
+            let aux = ChangeWindowAttributesAux::new().border_pixel(border_color);
259
+            conn.change_window_attributes(frame, &aux)?;
260
+        }
261
+        Ok(())
262
+    }
263
+
264
+    /// Map the frame window.
265
+    pub fn map_frame<C: X11Connection>(&self, conn: &C, client: Window) -> Result<(), Error> {
266
+        if let Some(&frame) = self.client_to_frame.get(&client) {
267
+            conn.map_window(frame)?;
268
+        }
269
+        Ok(())
270
+    }
271
+
272
+    /// Unmap the frame window.
273
+    pub fn unmap_frame<C: X11Connection>(&self, conn: &C, client: Window) -> Result<(), Error> {
274
+        if let Some(&frame) = self.client_to_frame.get(&client) {
275
+            conn.unmap_window(frame)?;
276
+        }
277
+        Ok(())
278
+    }
279
+
280
+    /// Get all frame windows.
281
+    pub fn all_frames(&self) -> impl Iterator<Item = Window> + '_ {
282
+        self.frame_to_client.keys().copied()
283
+    }
284
+}
285
+
286
+impl Default for FrameManager {
287
+    fn default() -> Self {
288
+        Self::new()
289
+    }
290
+}
gar/src/x11/mod.rsmodified
@@ -1,6 +1,8 @@
11
 pub mod connection;
22
 pub mod error;
33
 pub mod events;
4
+pub mod frame;
45
 
56
 pub use connection::Connection;
67
 pub use error::Error;
8
+pub use frame::FrameManager;