| 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 |