Rust · 33811 bytes Raw Blame History
1 mod monitor;
2 mod tree;
3 mod window;
4 mod workspace;
5
6 pub use monitor::Monitor;
7 pub use tree::{Direction, Node, Rect, SplitDirection};
8 pub use window::Window;
9 pub use workspace::Workspace;
10
11 use std::collections::HashMap;
12 use std::sync::{Arc, Mutex};
13 use x11rb::protocol::xproto::{ConnectionExt, Window as XWindow};
14
15 use crate::config::{Config, LuaConfig, LuaState, RuleActions, WindowMatch};
16 use crate::ipc::{IpcServer, I3IpcServer};
17 use crate::x11::Connection;
18 use crate::x11::events::DragState;
19 use crate::x11::FrameManager;
20 use crate::Result;
21
22 pub struct WindowManager {
23 pub conn: Connection,
24 pub config: Config,
25 pub lua_config: LuaConfig,
26 pub lua_state: Arc<Mutex<LuaState>>,
27 pub workspaces: Vec<Workspace>,
28 pub monitors: Vec<Monitor>,
29 pub windows: HashMap<XWindow, Window>,
30 pub focused_workspace: usize,
31 pub focused_window: Option<XWindow>,
32 pub focused_monitor: usize,
33 pub running: bool,
34 pub drag_state: Option<DragState>,
35 pub ipc_server: Option<IpcServer>,
36 /// i3-compatible IPC server for polybar integration
37 pub i3_ipc_server: Option<I3IpcServer>,
38 /// Timestamp of last pointer warp - used to suppress EnterNotify feedback loop
39 pub last_warp: std::time::Instant,
40 /// Frame manager for title bars
41 pub frames: FrameManager,
42 /// Focus history stack - most recently focused windows first (per workspace)
43 pub focus_history: Vec<XWindow>,
44 }
45
46 impl WindowManager {
47 pub fn new(conn: Connection) -> Result<Self> {
48 let workspaces: Vec<Workspace> = (1..=10)
49 .map(|i| Workspace::new(i, i.to_string()))
50 .collect();
51
52 // Initialize Lua config
53 let lua_config = LuaConfig::new().map_err(|e| crate::Error::Config(e.to_string()))?;
54 let lua_state = lua_config.state();
55
56 // Load configuration
57 lua_config
58 .load()
59 .map_err(|e| crate::Error::Config(e.to_string()))?;
60
61 // Get config values from Lua state
62 let config = lua_state.lock().unwrap().config.clone();
63
64 // Initialize IPC server (optional - graceful failure)
65 let ipc_server = match IpcServer::new() {
66 Ok(server) => Some(server),
67 Err(e) => {
68 tracing::warn!("Failed to start IPC server: {}", e);
69 None
70 }
71 };
72
73 // Initialize i3-compatible IPC server (optional - graceful failure)
74 let i3_ipc_server = match I3IpcServer::new() {
75 Ok(server) => Some(server),
76 Err(e) => {
77 tracing::warn!("Failed to start i3-compatible IPC server: {}", e);
78 None
79 }
80 };
81
82 // Subscribe to RandR events for hotplug
83 if let Err(e) = conn.subscribe_randr_events() {
84 tracing::warn!("Failed to subscribe to RandR events: {}", e);
85 }
86
87 // Detect monitors
88 let mut monitors = conn.detect_monitors().unwrap_or_else(|e| {
89 tracing::warn!("Failed to detect monitors: {}, using single screen", e);
90 vec![Monitor::new(
91 "default".to_string(),
92 0,
93 Rect::new(0, 0, conn.screen_width, conn.screen_height),
94 )]
95 });
96
97 // Ensure at least one monitor
98 if monitors.is_empty() {
99 monitors.push(Monitor::new(
100 "default".to_string(),
101 0,
102 Rect::new(0, 0, conn.screen_width, conn.screen_height),
103 ));
104 }
105
106 // i3-style: each monitor starts with one workspace (1, 2, 3...)
107 // Any workspace can be moved to any monitor dynamically
108 for (i, monitor) in monitors.iter_mut().enumerate() {
109 monitor.workspaces = vec![i]; // Just track initial workspace
110 monitor.active_workspace = i; // Monitor 0 shows ws 0, monitor 1 shows ws 1, etc.
111 tracing::debug!("Monitor '{}' starts with workspace {}", monitor.name, i + 1);
112 }
113
114 Ok(Self {
115 conn,
116 config,
117 lua_config,
118 lua_state,
119 workspaces,
120 monitors,
121 windows: HashMap::new(),
122 focused_workspace: 0,
123 focused_window: None,
124 focused_monitor: 0,
125 running: true,
126 drag_state: None,
127 ipc_server,
128 i3_ipc_server,
129 last_warp: std::time::Instant::now(),
130 frames: FrameManager::new(),
131 focus_history: Vec::new(),
132 })
133 }
134
135 pub fn current_workspace(&self) -> &Workspace {
136 &self.workspaces[self.focused_workspace]
137 }
138
139 pub fn current_workspace_mut(&mut self) -> &mut Workspace {
140 &mut self.workspaces[self.focused_workspace]
141 }
142
143 pub fn current_monitor(&self) -> &Monitor {
144 &self.monitors[self.focused_monitor]
145 }
146
147 /// Get the rectangle for the focused monitor.
148 pub fn screen_rect(&self) -> Rect {
149 self.monitors[self.focused_monitor].geometry
150 }
151
152 /// Get the rectangle for a specific workspace's monitor.
153 pub fn workspace_rect(&self, workspace_idx: usize) -> Rect {
154 self.monitor_for_workspace(workspace_idx)
155 .map(|m| m.geometry)
156 .unwrap_or_else(|| Rect::new(0, 0, self.conn.screen_width, self.conn.screen_height))
157 }
158
159 /// Find which monitor is currently displaying a workspace (i3-style).
160 pub fn monitor_for_workspace(&self, workspace_idx: usize) -> Option<&Monitor> {
161 self.monitors.iter().find(|m| m.active_workspace == workspace_idx)
162 }
163
164 /// Find the monitor index currently displaying a workspace (i3-style).
165 pub fn monitor_idx_for_workspace(&self, workspace_idx: usize) -> Option<usize> {
166 self.monitors.iter().position(|m| m.active_workspace == workspace_idx)
167 }
168
169 /// Refresh monitors (called on RandR screen change).
170 pub fn refresh_monitors(&mut self) -> Result<()> {
171 tracing::info!("Refreshing monitor configuration");
172
173 let mut new_monitors = self.conn.detect_monitors().unwrap_or_else(|e| {
174 tracing::warn!("Failed to detect monitors: {}, keeping current", e);
175 return self.monitors.clone();
176 });
177
178 if new_monitors.is_empty() {
179 new_monitors.push(Monitor::new(
180 "default".to_string(),
181 0,
182 Rect::new(0, 0, self.conn.screen_width, self.conn.screen_height),
183 ));
184 }
185
186 // Try to preserve workspace assignments from old monitors
187 // If we have more monitors now, new ones get next available workspaces
188 let old_mon_count = self.monitors.len();
189 let mut used_workspaces: std::collections::HashSet<usize> = std::collections::HashSet::new();
190
191 for (i, monitor) in new_monitors.iter_mut().enumerate() {
192 if i < old_mon_count {
193 // Preserve old monitor's workspace
194 monitor.active_workspace = self.monitors[i].active_workspace;
195 monitor.workspaces = vec![monitor.active_workspace];
196 used_workspaces.insert(monitor.active_workspace);
197 } else {
198 // New monitor - assign first unused workspace
199 let first_free = (0..self.workspaces.len())
200 .find(|ws| !used_workspaces.contains(ws))
201 .unwrap_or(0);
202 monitor.active_workspace = first_free;
203 monitor.workspaces = vec![first_free];
204 used_workspaces.insert(first_free);
205 }
206 tracing::info!("Monitor '{}' showing workspace {}", monitor.name, monitor.active_workspace + 1);
207 }
208
209 self.monitors = new_monitors;
210
211 // Ensure focused_monitor is valid
212 if self.focused_monitor >= self.monitors.len() {
213 self.focused_monitor = 0;
214 }
215
216 // Update focused_workspace to match the focused monitor
217 self.focused_workspace = self.monitors[self.focused_monitor].active_workspace;
218
219 // Re-apply layout for visible workspaces
220 self.apply_layout()?;
221
222 Ok(())
223 }
224
225 /// Check if a window should be managed (not override-redirect, etc.)
226 pub fn should_manage(&self, window: XWindow) -> bool {
227 // Don't manage the root window
228 if window == self.conn.root {
229 return false;
230 }
231 // Already managing?
232 if self.windows.contains_key(&window) {
233 return false;
234 }
235 true
236 }
237
238 /// Add a window to management.
239 pub fn manage_window(&mut self, window: XWindow) {
240 if !self.should_manage(window) {
241 return;
242 }
243
244 tracing::info!("Managing window {} (tiled)", window);
245
246 // Track the window with current workspace
247 let win = Window::new(window, self.focused_workspace);
248 self.windows.insert(window, win);
249
250 // Set EWMH _NET_WM_DESKTOP
251 let _ = self.conn.set_window_desktop(window, self.focused_workspace as u32);
252
253 // Insert into current workspace's tree with smart splitting
254 let focused = self.current_workspace().focused;
255 let screen = self.screen_rect();
256 self.current_workspace_mut()
257 .tree
258 .insert_with_rect(window, focused, screen);
259 self.current_workspace_mut().focused = Some(window);
260 self.focused_window = Some(window);
261
262 // Update EWMH client lists
263 self.update_client_lists();
264 }
265
266 /// Add a window to management on a specific workspace.
267 pub fn manage_window_on_workspace(&mut self, window: XWindow, workspace_idx: usize) {
268 if !self.should_manage(window) {
269 return;
270 }
271
272 tracing::info!("Managing window {} on workspace {} (tiled)", window, workspace_idx + 1);
273
274 // Track the window
275 let win = Window::new(window, workspace_idx);
276 self.windows.insert(window, win);
277
278 // Set EWMH _NET_WM_DESKTOP
279 let _ = self.conn.set_window_desktop(window, workspace_idx as u32);
280
281 // Insert into target workspace's BSP tree
282 let focused = self.workspaces[workspace_idx].focused;
283 let screen = self.screen_rect();
284 self.workspaces[workspace_idx].tree.insert_with_rect(window, focused, screen);
285
286 // Update EWMH client lists
287 self.update_client_lists();
288 }
289
290 /// Add a window to management as a floating window on a specific workspace.
291 pub fn manage_window_floating_on_workspace(&mut self, window: XWindow, workspace_idx: usize) {
292 if !self.should_manage(window) {
293 return;
294 }
295
296 tracing::info!("Managing window {} on workspace {} (floating)", window, workspace_idx + 1);
297
298 // Calculate floating geometry
299 let screen = self.screen_rect();
300 let float_width = (screen.width * 4 / 5).max(400);
301 let float_height = (screen.height * 4 / 5).max(300);
302 let float_x = screen.x + (screen.width as i16 - float_width as i16) / 2;
303 let float_y = screen.y + (screen.height as i16 - float_height as i16) / 2;
304
305 // Track the window with floating state
306 let mut win = Window::new(window, workspace_idx);
307 win.floating = true;
308 win.floating_geometry = Rect::new(float_x, float_y, float_width, float_height);
309 self.windows.insert(window, win);
310
311 // Set EWMH _NET_WM_DESKTOP
312 let _ = self.conn.set_window_desktop(window, workspace_idx as u32);
313
314 // Add to target workspace's floating list
315 self.workspaces[workspace_idx].add_floating(window);
316
317 // Update EWMH client lists
318 self.update_client_lists();
319 }
320
321 /// Add a window to management as a floating window.
322 pub fn manage_window_floating(&mut self, window: XWindow) {
323 if !self.should_manage(window) {
324 return;
325 }
326
327 tracing::info!("Managing window {} (floating)", window);
328
329 // Calculate centered floating geometry
330 let screen = self.screen_rect();
331 let float_width = 640.min(screen.width.saturating_sub(40));
332 let float_height = 480.min(screen.height.saturating_sub(40));
333 let float_x = screen.x + (screen.width as i16 - float_width as i16) / 2;
334 let float_y = screen.y + (screen.height as i16 - float_height as i16) / 2;
335
336 // Track the window with floating state
337 let mut win = Window::new(window, self.focused_workspace);
338 win.floating = true;
339 win.floating_geometry = Rect::new(float_x, float_y, float_width, float_height);
340 self.windows.insert(window, win);
341
342 // Set EWMH _NET_WM_DESKTOP
343 let _ = self.conn.set_window_desktop(window, self.focused_workspace as u32);
344
345 // Add to floating list (on top)
346 self.current_workspace_mut().add_floating(window);
347 self.current_workspace_mut().focused = Some(window);
348 self.focused_window = Some(window);
349
350 // Update EWMH client lists
351 self.update_client_lists();
352 }
353
354 /// Create a frame for a window if title bars are enabled.
355 /// Returns the frame window ID if created.
356 pub fn create_frame_for_window(&mut self, window: XWindow) -> Option<XWindow> {
357 if !self.config.titlebar_enabled {
358 return None;
359 }
360
361 // Get window title for display
362 let title = self.conn.get_window_title(window).unwrap_or_default();
363
364 // Create frame with initial geometry (will be updated by apply_layout)
365 let screen = self.screen_rect();
366 let frame = match self.frames.create_frame(
367 &self.conn.conn,
368 self.conn.root,
369 window,
370 screen.x,
371 screen.y,
372 400, // Initial width, will be adjusted
373 300, // Initial height, will be adjusted
374 self.config.titlebar_height as u16,
375 self.config.border_width as u16,
376 self.config.border_color_unfocused,
377 self.config.titlebar_color_unfocused,
378 ) {
379 Ok(f) => f,
380 Err(e) => {
381 tracing::error!("Failed to create frame for window {}: {}", window, e);
382 return None;
383 }
384 };
385
386 // Update window state with frame and title
387 if let Some(win) = self.windows.get_mut(&window) {
388 win.frame = Some(frame);
389 win.title = title;
390 }
391
392 Some(frame)
393 }
394
395 /// Remove a window from management.
396 pub fn unmanage_window(&mut self, window: XWindow) {
397 if let Some(win) = self.windows.remove(&window) {
398 let ws_idx = win.workspace;
399 tracing::info!("Unmanaging window {} from workspace {}", window, ws_idx + 1);
400
401 // Destroy frame if it exists
402 if win.frame.is_some() {
403 if let Err(e) = self.frames.destroy_frame(&self.conn.conn, self.conn.root, window) {
404 tracing::warn!("Failed to destroy frame for window {}: {}", window, e);
405 }
406 }
407
408 // Remove from the window's actual workspace (not current_workspace!)
409 if win.floating {
410 self.workspaces[ws_idx].remove_floating(window);
411 } else {
412 self.workspaces[ws_idx].tree.remove(window);
413 }
414
415 // Remove from focus history
416 self.focus_history.retain(|&w| w != window);
417
418 // Update focus if this was the focused window
419 if self.focused_window == Some(window) {
420 // Find next window from focus history that's on this workspace
421 let next_from_history = self.focus_history.iter()
422 .find(|&&w| self.windows.get(&w).map(|win| win.workspace == ws_idx).unwrap_or(false))
423 .copied();
424
425 // Fall back to first window in tree or floating list if no history
426 self.focused_window = next_from_history
427 .or_else(|| self.workspaces[ws_idx].tree.first_window())
428 .or_else(|| self.workspaces[ws_idx].floating.last().copied());
429 self.workspaces[ws_idx].focused = self.focused_window;
430 }
431
432 // Update EWMH client lists
433 self.update_client_lists();
434 }
435 }
436
437 /// Update _NET_CLIENT_LIST and _NET_CLIENT_LIST_STACKING on root window.
438 pub fn update_client_lists(&self) {
439 let windows: Vec<u32> = self.windows.keys().copied().collect();
440 if let Err(e) = self.conn.update_client_list(&windows) {
441 tracing::warn!("Failed to update client list: {}", e);
442 }
443 // For stacking order, we use the same list for now (tiling WM doesn't have true stacking)
444 // A more sophisticated implementation would order by focus history
445 if let Err(e) = self.conn.update_client_list_stacking(&windows) {
446 tracing::warn!("Failed to update client list stacking: {}", e);
447 }
448 }
449
450 /// Check window rules and return actions to apply.
451 pub fn check_rules(&self, window: XWindow) -> RuleActions {
452 let state = self.lua_state.lock().unwrap();
453
454 // Get window properties
455 let (instance, class) = self.conn.get_wm_class(window).unwrap_or_default();
456 let title = self.conn.get_window_title(window).unwrap_or_default();
457
458 tracing::debug!("Checking rules for window {}: class={}, instance={}, title={}",
459 window, class, instance, title);
460
461 let mut result = RuleActions::default();
462
463 for rule in &state.rules {
464 let matches = rule_matches(&rule.match_criteria, &class, &instance, &title);
465 if matches {
466 tracing::info!("Rule matched for window {}: {:?}", window, rule.actions);
467 // Merge actions (later rules override earlier ones)
468 if rule.actions.floating.is_some() {
469 result.floating = rule.actions.floating;
470 }
471 if rule.actions.workspace.is_some() {
472 result.workspace = rule.actions.workspace;
473 }
474 }
475 }
476
477 result
478 }
479
480 /// Set focus to a window.
481 /// If `warp_pointer` is true, the mouse pointer will be moved to the window center.
482 /// Use true for keyboard navigation, false for mouse-initiated focus changes.
483 pub fn set_focus(&mut self, window: XWindow, warp_pointer: bool) -> Result<()> {
484 self.focused_window = Some(window);
485 self.current_workspace_mut().focused = Some(window);
486
487 // Update focus history - move window to front
488 self.focus_history.retain(|&w| w != window);
489 self.focus_history.insert(0, window);
490
491 // Clear urgency when window receives focus
492 if let Some(win) = self.windows.get_mut(&window) {
493 if win.urgent {
494 tracing::debug!("Clearing urgency for window {} on focus", window);
495 win.urgent = false;
496 }
497 }
498
499 self.conn.set_focus(window)?;
500 self.conn.set_active_window(Some(window))?;
501 self.update_borders()?;
502
503 // Warp pointer to center of focused window (mouse follows focus)
504 if warp_pointer {
505 if let Err(e) = self.conn.warp_pointer_to_window(window) {
506 tracing::warn!("Failed to warp pointer: {}", e);
507 }
508 // Record warp time to suppress EnterNotify feedback loop
509 self.last_warp = std::time::Instant::now();
510 }
511
512 Ok(())
513 }
514
515 /// Toggle fullscreen state for a window.
516 pub fn toggle_fullscreen(&mut self, window: XWindow) -> Result<()> {
517 let win = match self.windows.get_mut(&window) {
518 Some(w) => w,
519 None => return Ok(()),
520 };
521
522 let ws_idx = win.workspace;
523
524 if win.fullscreen {
525 // Exit fullscreen - restore previous state
526 tracing::info!("Window {} exiting fullscreen", window);
527
528 win.fullscreen = false;
529
530 // Restore previous floating state
531 let was_floating = win.pre_fullscreen_floating;
532 if was_floating != win.floating {
533 if was_floating {
534 // Was floating before - remove from tree, add to floating
535 self.workspaces[ws_idx].tree.remove(window);
536 self.workspaces[ws_idx].add_floating(window);
537 } else {
538 // Was tiled before - remove from floating, add to tree
539 self.workspaces[ws_idx].remove_floating(window);
540 let focused = self.workspaces[ws_idx].focused;
541 let screen = self.screen_rect();
542 self.workspaces[ws_idx].tree.insert_with_rect(window, focused, screen);
543 }
544 if let Some(w) = self.windows.get_mut(&window) {
545 w.floating = was_floating;
546 }
547 }
548
549 // Clear EWMH fullscreen state
550 let _ = self.conn.set_window_state(window, &[]);
551 } else {
552 // Enter fullscreen
553 tracing::info!("Window {} entering fullscreen", window);
554
555 // Save current state
556 win.pre_fullscreen_floating = win.floating;
557 win.fullscreen = true;
558
559 // Set EWMH fullscreen state
560 let _ = self.conn.set_window_state(window, &[self.conn.net_wm_state_fullscreen]);
561 }
562
563 // Re-apply layout (fullscreen windows get special treatment in apply_layout)
564 self.apply_layout()?;
565 self.conn.flush()?;
566 Ok(())
567 }
568
569 /// Set fullscreen state for a window explicitly (for EWMH client messages).
570 pub fn set_fullscreen(&mut self, window: XWindow, fullscreen: bool) -> Result<()> {
571 let is_fullscreen = self.windows.get(&window).map(|w| w.fullscreen).unwrap_or(false);
572 if is_fullscreen != fullscreen {
573 self.toggle_fullscreen(window)?;
574 }
575 Ok(())
576 }
577
578 /// Warp pointer to center of a monitor (for focus without windows)
579 pub fn warp_to_monitor(&mut self, monitor_idx: usize) -> Result<()> {
580 let geom = self.monitors[monitor_idx].geometry;
581 let center_x = geom.x + (geom.width / 2) as i16;
582 let center_y = geom.y + (geom.height / 2) as i16;
583
584 self.conn.conn.warp_pointer(
585 x11rb::NONE,
586 self.conn.root,
587 0, 0, 0, 0,
588 center_x,
589 center_y,
590 )?;
591 self.last_warp = std::time::Instant::now();
592 self.conn.flush()?;
593 Ok(())
594 }
595
596 /// Update border colors for all visible windows based on focus and urgency state.
597 pub fn update_borders(&mut self) -> Result<()> {
598 let focused = self.focused_window;
599 let focused_color = self.config.border_color_focused;
600 let unfocused_color = self.config.border_color_unfocused;
601 let urgent_color = self.config.border_color_urgent;
602 let border_width = self.config.border_width;
603
604 // Get all visible workspace indices
605 let visible_ws: Vec<usize> = self.monitors.iter().map(|m| m.active_workspace).collect();
606
607 // Update borders for all windows on visible workspaces
608 for ws_idx in visible_ws {
609 for window in self.workspaces[ws_idx].all_windows() {
610 // Check if window is urgent (and not focused - focused clears urgency)
611 let is_urgent = self.windows.get(&window)
612 .map(|w| w.urgent && Some(window) != focused)
613 .unwrap_or(false);
614
615 let color = if is_urgent {
616 urgent_color
617 } else if Some(window) == focused {
618 focused_color
619 } else {
620 unfocused_color
621 };
622
623 // If window has a frame, set border on the frame instead
624 if self.windows.get(&window).and_then(|w| w.frame).is_some() {
625 self.frames.set_frame_border(&self.conn.conn, window, color)?;
626 } else {
627 self.conn.set_border(window, border_width, color)?;
628 }
629 }
630 }
631 Ok(())
632 }
633
634 /// Apply the current layout to all visible windows across all monitors.
635 /// Each monitor displays its active_workspace.
636 /// Stacking order: tiled windows at bottom, floating windows on top (in list order).
637 pub fn apply_layout(&mut self) -> Result<()> {
638 use x11rb::protocol::xproto::{ConfigureWindowAux, ConnectionExt, StackMode};
639
640 let border_width = self.config.border_width;
641 let gap_outer = self.config.gap_outer as i16;
642 let gap_inner = self.config.gap_inner as i16;
643 let half_gap = gap_inner / 2;
644
645 // Collect visible workspaces (one per monitor)
646 let visible_workspaces: Vec<(usize, Rect)> = self.monitors
647 .iter()
648 .map(|m| (m.active_workspace, m.geometry))
649 .collect();
650
651 // Layout each monitor's active workspace
652 for (ws_idx, screen) in &visible_workspaces {
653 // Check for fullscreen windows on this workspace
654 let fullscreen_windows: Vec<XWindow> = self.windows.iter()
655 .filter(|(_, w)| w.workspace == *ws_idx && w.fullscreen)
656 .map(|(id, _)| *id)
657 .collect();
658
659 // If there's a fullscreen window, it takes the whole monitor
660 if let Some(&fs_window) = fullscreen_windows.first() {
661 tracing::debug!(
662 "apply_layout: FULLSCREEN window={} on monitor {:?}",
663 fs_window, screen
664 );
665
666 // Configure fullscreen window to cover entire monitor (no gaps, no borders)
667 self.conn.configure_window(
668 fs_window,
669 screen.x,
670 screen.y,
671 screen.width,
672 screen.height,
673 0, // No border for fullscreen
674 )?;
675
676 // Raise fullscreen window above everything
677 let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
678 self.conn.conn.configure_window(fs_window, &aux)?;
679
680 // Skip normal layout for this workspace - fullscreen window covers everything
681 continue;
682 }
683
684 let work_area = Rect::new(
685 screen.x + gap_outer,
686 screen.y + gap_outer,
687 screen.width.saturating_sub(2 * gap_outer as u16),
688 screen.height.saturating_sub(2 * gap_outer as u16),
689 );
690
691 let ws = &self.workspaces[*ws_idx];
692 tracing::debug!(
693 "apply_layout: ws={} screen={:?}, work_area={:?}, tiled={}, floating={}",
694 ws_idx + 1, screen, work_area,
695 ws.tree.window_count(), ws.floating.len()
696 );
697
698 // Get titlebar settings
699 let titlebar_enabled = self.config.titlebar_enabled;
700 let titlebar_height = self.config.titlebar_height as u16;
701
702 // 1. Configure tiled windows from the BSP tree
703 let geometries = ws.tree.calculate_geometries(work_area);
704 for (window, rect) in &geometries {
705 // Apply inner gap: shrink each window by half_gap on each side
706 let gapped_x = rect.x + half_gap;
707 let gapped_y = rect.y + half_gap;
708 let gapped_width = rect.width.saturating_sub(gap_inner as u16);
709 let gapped_height = rect.height.saturating_sub(gap_inner as u16);
710
711 // Account for border width
712 let final_width = gapped_width.saturating_sub(2 * border_width as u16);
713 let final_height = gapped_height.saturating_sub(2 * border_width as u16);
714
715 // Check if window has a frame
716 let has_frame = self.windows.get(window).and_then(|w| w.frame).is_some();
717
718 if has_frame && titlebar_enabled {
719 // Configure frame (includes titlebar height)
720 let client_height = final_height.saturating_sub(titlebar_height);
721 self.frames.configure_frame(
722 &self.conn.conn,
723 *window,
724 gapped_x,
725 gapped_y,
726 final_width.max(1),
727 client_height.max(1),
728 titlebar_height,
729 border_width as u16,
730 )?;
731
732 tracing::debug!(
733 "apply_layout: TILED+FRAME window={} at ({}, {}) size {}x{} (titlebar: {})",
734 window, gapped_x, gapped_y, final_width.max(1), final_height.max(1), titlebar_height
735 );
736 } else {
737 tracing::debug!(
738 "apply_layout: TILED window={} at ({}, {}) size {}x{}",
739 window, gapped_x, gapped_y, final_width.max(1), final_height.max(1)
740 );
741
742 self.conn.configure_window(
743 *window,
744 gapped_x,
745 gapped_y,
746 final_width.max(1),
747 final_height.max(1),
748 border_width,
749 )?;
750 }
751 }
752
753 // 2. Configure floating windows and stack them above tiled
754 let floating_ids: Vec<XWindow> = ws.floating.clone();
755
756 for window_id in floating_ids {
757 // Get the window's floating geometry from our state
758 if let Some(win) = self.windows.get(&window_id) {
759 let geom = win.floating_geometry;
760 let adjusted_width = geom.width.saturating_sub(2 * border_width as u16);
761 let adjusted_height = geom.height.saturating_sub(2 * border_width as u16);
762
763 let has_frame = win.frame.is_some();
764
765 if has_frame && titlebar_enabled {
766 // Configure frame for floating window
767 let client_height = adjusted_height.saturating_sub(titlebar_height);
768 self.frames.configure_frame(
769 &self.conn.conn,
770 window_id,
771 geom.x,
772 geom.y,
773 adjusted_width.max(1),
774 client_height.max(1),
775 titlebar_height,
776 border_width as u16,
777 )?;
778
779 // Raise frame to top of stack
780 if let Some(frame) = self.frames.frame_for_client(window_id) {
781 let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
782 self.conn.conn.configure_window(frame, &aux)?;
783 }
784
785 tracing::debug!(
786 "apply_layout: FLOATING+FRAME window={} at ({}, {}) size {}x{} (raising)",
787 window_id, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1)
788 );
789 } else {
790 tracing::debug!(
791 "apply_layout: FLOATING window={} at ({}, {}) size {}x{} (raising)",
792 window_id, geom.x, geom.y, adjusted_width.max(1), adjusted_height.max(1)
793 );
794
795 // Configure geometry
796 self.conn.configure_window(
797 window_id,
798 geom.x,
799 geom.y,
800 adjusted_width.max(1),
801 adjusted_height.max(1),
802 border_width,
803 )?;
804
805 // Raise to top of stack (each subsequent window goes above the previous)
806 let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
807 self.conn.conn.configure_window(window_id, &aux)?;
808 }
809 } else {
810 tracing::warn!("apply_layout: floating window {} not in windows map!", window_id);
811 }
812 }
813 }
814
815 self.update_borders()?;
816 self.conn.flush()?;
817 Ok(())
818 }
819
820 /// Check if a workspace is currently visible (active on any monitor).
821 pub fn is_workspace_visible(&self, ws_idx: usize) -> bool {
822 self.monitors.iter().any(|m| m.active_workspace == ws_idx)
823 }
824
825 /// Get all currently visible workspace indices.
826 pub fn visible_workspaces(&self) -> Vec<usize> {
827 self.monitors.iter().map(|m| m.active_workspace).collect()
828 }
829 }
830
831 /// Check if window properties match rule criteria (case-insensitive substring match).
832 fn rule_matches(criteria: &WindowMatch, class: &str, instance: &str, title: &str) -> bool {
833 let class_lower = class.to_lowercase();
834 let instance_lower = instance.to_lowercase();
835 let title_lower = title.to_lowercase();
836
837 // All specified criteria must match
838 if let Some(ref c) = criteria.class {
839 let c_lower: String = c.to_lowercase();
840 if !class_lower.contains(&c_lower) {
841 return false;
842 }
843 }
844 if let Some(ref i) = criteria.instance {
845 let i_lower: String = i.to_lowercase();
846 if !instance_lower.contains(&i_lower) {
847 return false;
848 }
849 }
850 if let Some(ref t) = criteria.title {
851 let t_lower: String = t.to_lowercase();
852 if !title_lower.contains(&t_lower) {
853 return false;
854 }
855 }
856
857 // At least one criterion must be specified
858 criteria.class.is_some() || criteria.instance.is_some() || criteria.title.is_some()
859 }
860