Rust · 143772 bytes Raw Blame History
1 use std::process::Command;
2 use std::io::Write;
3
4 use x11rb::connection::Connection as X11Connection;
5
6 /// Debug logging to file (since RUST_LOG doesn't work with auto-started WM)
7 fn debug_log(msg: &str) {
8 if let Ok(mut f) = std::fs::OpenOptions::new()
9 .create(true)
10 .append(true)
11 .open("/tmp/gar-tiled-resize.log")
12 {
13 let _ = writeln!(f, "{}", msg);
14 }
15 }
16
17 /// Reap any zombie child processes to prevent accumulation.
18 /// Called periodically from the event loop.
19 fn reap_zombies() {
20 unsafe {
21 // WNOHANG = 1, reap any child without blocking
22 while libc::waitpid(-1, std::ptr::null_mut(), libc::WNOHANG) > 0 {}
23 }
24 }
25
26 /// Signal systemd that the graphical session has started.
27 /// This allows user services bound to graphical-session.target to start.
28 fn start_graphical_session() {
29 // Import DISPLAY so user services can connect to X
30 if let Ok(display_val) = std::env::var("DISPLAY") {
31 match Command::new("systemctl")
32 .args(["--user", "import-environment", "DISPLAY", "XAUTHORITY"])
33 .output()
34 {
35 Ok(output) => {
36 if output.status.success() {
37 tracing::info!("Imported DISPLAY={} to systemd user session", display_val);
38 } else {
39 let stderr = String::from_utf8_lossy(&output.stderr);
40 tracing::error!("Failed to import DISPLAY: {}", stderr);
41 }
42 }
43 Err(e) => tracing::error!("Failed to run systemctl import-environment: {}", e),
44 }
45 } else {
46 tracing::warn!("DISPLAY not set, skipping systemd import");
47 }
48
49 // Start graphical-session.target
50 match Command::new("systemctl")
51 .args(["--user", "start", "graphical-session.target"])
52 .output()
53 {
54 Ok(output) => {
55 if output.status.success() {
56 tracing::info!("Started graphical-session.target");
57 } else {
58 let stderr = String::from_utf8_lossy(&output.stderr);
59 tracing::error!("Failed to start graphical-session.target: {}", stderr);
60 }
61 }
62 Err(e) => {
63 tracing::error!("Failed to run systemctl start graphical-session.target: {}", e);
64 }
65 }
66 }
67
68 /// Signal systemd that the graphical session has ended.
69 /// This stops user services bound to graphical-session.target.
70 fn stop_graphical_session() {
71 match Command::new("systemctl")
72 .args(["--user", "stop", "graphical-session.target"])
73 .status()
74 {
75 Ok(status) if status.success() => {
76 tracing::info!("Stopped graphical-session.target");
77 }
78 Ok(_) => {
79 tracing::debug!("graphical-session.target was not running");
80 }
81 Err(e) => {
82 tracing::warn!("Failed to stop graphical-session.target: {}", e);
83 }
84 }
85 }
86
87 /// Get garbar socket path
88 fn garbar_socket_path() -> String {
89 std::env::var("XDG_RUNTIME_DIR")
90 .map(|dir| format!("{}/garbar.sock", dir))
91 .unwrap_or_else(|_| "/tmp/garbar.sock".to_string())
92 }
93
94 /// Check if garbar is healthy (socket exists)
95 fn is_garbar_healthy() -> bool {
96 std::path::Path::new(&garbar_socket_path()).exists()
97 }
98
99 /// Kill any stale garbar process that isn't responding
100 fn cleanup_stale_garbar() {
101 let pid_path = std::env::var("XDG_RUNTIME_DIR")
102 .map(|dir| format!("{}/garbar.pid", dir))
103 .unwrap_or_else(|_| "/tmp/garbar.pid".to_string());
104
105 if let Ok(pid_str) = std::fs::read_to_string(&pid_path) {
106 if let Ok(pid) = pid_str.trim().parse::<i32>() {
107 // Check if process exists but socket doesn't (stale process)
108 let proc_path = format!("/proc/{}", pid);
109 if std::path::Path::new(&proc_path).exists() && !is_garbar_healthy() {
110 tracing::warn!("Killing stale garbar process (PID {})", pid);
111 unsafe {
112 libc::kill(pid, libc::SIGKILL);
113 }
114 std::thread::sleep(std::time::Duration::from_millis(100));
115 }
116 }
117 }
118 }
119
120 fn spawn_garbar() -> Option<std::process::Child> {
121 tracing::info!("Spawning garbar...");
122
123 // Clean up any stale garbar process first
124 cleanup_stale_garbar();
125
126 // Try to find garbar in PATH or common locations
127 let garbar_cmd = which_garbar().unwrap_or_else(|| "garbar".to_string());
128
129 // Determine i3 IPC socket path (same as gar's i3_server.rs)
130 let i3sock = std::env::var("XDG_RUNTIME_DIR")
131 .map(|dir| format!("{}/gar-i3.sock", dir))
132 .unwrap_or_else(|_| "/tmp/gar-i3.sock".to_string());
133
134 // Inherit DISPLAY from current environment, fallback to :0
135 let x_display = std::env::var("DISPLAY").unwrap_or_else(|_| ":0".to_string());
136
137 match Command::new(&garbar_cmd)
138 .arg("daemon")
139 .env("I3SOCK", &i3sock)
140 .env("DISPLAY", &x_display)
141 .spawn()
142 {
143 Ok(child) => {
144 tracing::info!("garbar started (PID {}), DISPLAY={}, I3SOCK={}", child.id(), x_display, i3sock);
145
146 // Wait briefly and verify garbar becomes healthy
147 for attempt in 1..=10 {
148 std::thread::sleep(std::time::Duration::from_millis(200));
149 if is_garbar_healthy() {
150 tracing::info!("garbar socket ready after {}ms", attempt * 200);
151 return Some(child);
152 }
153 }
154
155 // Socket never appeared - garbar might be stuck
156 tracing::warn!("garbar socket not ready after 2s, process may be stuck");
157 Some(child)
158 }
159 Err(e) => {
160 tracing::error!("Failed to spawn garbar: {}", e);
161 tracing::info!("Hint: Ensure garbar is installed and in PATH, or use 'cargo install --path garbar'");
162 None
163 }
164 }
165 }
166
167 /// Find garbar executable
168 fn which_garbar() -> Option<String> {
169 // Check if garbar is in PATH
170 if Command::new("which")
171 .arg("garbar")
172 .output()
173 .map(|o| o.status.success())
174 .unwrap_or(false)
175 {
176 return Some("garbar".to_string());
177 }
178
179 // Check common cargo install location
180 if let Ok(home) = std::env::var("HOME") {
181 let cargo_bin = format!("{}/.cargo/bin/garbar", home);
182 if std::path::Path::new(&cargo_bin).exists() {
183 return Some(cargo_bin);
184 }
185 }
186
187 // Check /usr/local/bin
188 if std::path::Path::new("/usr/local/bin/garbar").exists() {
189 return Some("/usr/local/bin/garbar".to_string());
190 }
191
192 None
193 }
194
195 /// Stop garbar gracefully by sending SIGTERM.
196 fn stop_garbar(child: &mut std::process::Child) {
197 tracing::info!("Stopping garbar (PID {})...", child.id());
198
199 // Send SIGTERM for graceful shutdown
200 unsafe {
201 libc::kill(child.id() as i32, libc::SIGTERM);
202 }
203
204 // Wait briefly for it to exit
205 match child.try_wait() {
206 Ok(Some(status)) => {
207 tracing::info!("garbar exited with status: {}", status);
208 }
209 Ok(None) => {
210 // Give it a moment to shut down
211 std::thread::sleep(std::time::Duration::from_millis(100));
212 match child.try_wait() {
213 Ok(Some(status)) => {
214 tracing::info!("garbar exited with status: {}", status);
215 }
216 Ok(None) => {
217 // Force kill if still running
218 tracing::warn!("garbar did not exit gracefully, sending SIGKILL");
219 let _ = child.kill();
220 }
221 Err(e) => {
222 tracing::warn!("Error waiting for garbar: {}", e);
223 }
224 }
225 }
226 Err(e) => {
227 tracing::warn!("Error checking garbar status: {}", e);
228 }
229 }
230 }
231
232 /// Signal garbar to reload its configuration (SIGHUP).
233 fn reload_garbar(child: &std::process::Child) {
234 tracing::info!("Signaling garbar to reload (PID {})...", child.id());
235 unsafe {
236 libc::kill(child.id() as i32, libc::SIGHUP);
237 }
238 }
239
240 // ============================================================================
241 // garnotify integration - auto-spawn notification daemon
242 // ============================================================================
243
244 /// Get garnotify socket path
245 fn garnotify_socket_path() -> String {
246 std::env::var("XDG_RUNTIME_DIR")
247 .map(|dir| format!("{}/garnotify.sock", dir))
248 .unwrap_or_else(|_| "/tmp/garnotify.sock".to_string())
249 }
250
251 /// Check if garnotify is healthy (socket exists)
252 fn is_garnotify_healthy() -> bool {
253 std::path::Path::new(&garnotify_socket_path()).exists()
254 }
255
256 /// Spawn garnotify notification daemon
257 fn spawn_garnotify() -> Option<std::process::Child> {
258 tracing::info!("Spawning garnotify...");
259
260 // Try to find garnotify in PATH or common locations
261 let garnotify_cmd = which_garnotify().unwrap_or_else(|| "garnotify".to_string());
262
263 // Inherit DISPLAY from current environment
264 let x_display = std::env::var("DISPLAY").unwrap_or_else(|_| ":0".to_string());
265
266 match Command::new(&garnotify_cmd)
267 .arg("daemon")
268 .env("DISPLAY", &x_display)
269 .spawn()
270 {
271 Ok(child) => {
272 tracing::info!("garnotify started (PID {}), DISPLAY={}", child.id(), x_display);
273
274 // Wait briefly and verify garnotify becomes healthy
275 for attempt in 1..=10 {
276 std::thread::sleep(std::time::Duration::from_millis(200));
277 if is_garnotify_healthy() {
278 tracing::info!("garnotify socket ready after {}ms", attempt * 200);
279 return Some(child);
280 }
281 }
282
283 // Socket never appeared - might still be starting up
284 tracing::warn!("garnotify socket not ready after 2s, may still be starting");
285 Some(child)
286 }
287 Err(e) => {
288 tracing::error!("Failed to spawn garnotify: {}", e);
289 tracing::info!("Hint: Ensure garnotify is installed and in PATH");
290 None
291 }
292 }
293 }
294
295 /// Find garnotify executable
296 fn which_garnotify() -> Option<String> {
297 // Check if garnotify is in PATH
298 if Command::new("which")
299 .arg("garnotify")
300 .output()
301 .map(|o| o.status.success())
302 .unwrap_or(false)
303 {
304 return Some("garnotify".to_string());
305 }
306
307 // Check common cargo install location
308 if let Ok(home) = std::env::var("HOME") {
309 let cargo_bin = format!("{}/.cargo/bin/garnotify", home);
310 if std::path::Path::new(&cargo_bin).exists() {
311 return Some(cargo_bin);
312 }
313 }
314
315 // Check /usr/local/bin
316 if std::path::Path::new("/usr/local/bin/garnotify").exists() {
317 return Some("/usr/local/bin/garnotify".to_string());
318 }
319
320 None
321 }
322
323 /// Stop garnotify gracefully by sending SIGTERM.
324 fn stop_garnotify(child: &mut std::process::Child) {
325 tracing::info!("Stopping garnotify (PID {})...", child.id());
326
327 // Send SIGTERM for graceful shutdown
328 unsafe {
329 libc::kill(child.id() as i32, libc::SIGTERM);
330 }
331
332 // Wait briefly for it to exit
333 match child.try_wait() {
334 Ok(Some(status)) => {
335 tracing::info!("garnotify exited with status: {}", status);
336 }
337 Ok(None) => {
338 std::thread::sleep(std::time::Duration::from_millis(100));
339 match child.try_wait() {
340 Ok(Some(status)) => {
341 tracing::info!("garnotify exited with status: {}", status);
342 }
343 Ok(None) => {
344 tracing::warn!("garnotify did not exit gracefully, sending SIGKILL");
345 let _ = child.kill();
346 }
347 Err(e) => {
348 tracing::warn!("Error waiting for garnotify: {}", e);
349 }
350 }
351 }
352 Err(e) => {
353 tracing::warn!("Error checking garnotify status: {}", e);
354 }
355 }
356 }
357
358 use x11rb::protocol::xproto::{
359 ButtonPressEvent, ButtonReleaseEvent, ClientMessageEvent, ConfigureRequestEvent,
360 ConfigureWindowAux, ConnectionExt, DestroyNotifyEvent, EnterNotifyEvent, EventMask,
361 ExposeEvent, KeyPressEvent, MapRequestEvent, ModMask, MotionNotifyEvent, NotifyMode,
362 PropertyNotifyEvent, StackMode, UnmapNotifyEvent,
363 };
364 use x11rb::protocol::Event;
365
366 use crate::config::Action;
367 use crate::core::{Direction, Node, Rect, WindowManager};
368 use crate::Result;
369
370 /// State for mouse drag operations
371 #[derive(Debug, Clone)]
372 pub enum DragState {
373 Move {
374 window: u32,
375 start_x: i16,
376 start_y: i16,
377 start_geometry: Rect,
378 },
379 Resize {
380 window: u32,
381 start_x: i16,
382 start_y: i16,
383 start_geometry: Rect,
384 edge: ResizeEdge,
385 },
386 /// Dragging a tiled window to swap with another
387 TiledSwap {
388 window: u32,
389 /// Grab point offset from window origin (for smooth dragging)
390 grab_offset_x: i16,
391 grab_offset_y: i16,
392 /// Original tree state for reverting if cancelled
393 original_tree: Node,
394 /// Cached geometries of all tiled windows (updated after swaps)
395 tiled_geometries: Vec<(u32, Rect)>,
396 /// Currently hovered swap target (for visual feedback)
397 hover_target: Option<u32>,
398 /// Original workspace index
399 workspace: usize,
400 },
401 /// Dragging on the gap between tiled windows to resize
402 TiledResize {
403 /// Direction of resize (Right = vertical split, Down = horizontal split)
404 direction: Direction,
405 /// Starting cursor position (x for horizontal, y for vertical)
406 start_pos: i16,
407 /// Starting ratio of the split
408 start_ratio: f32,
409 /// Window whose split we're adjusting
410 window: u32,
411 /// Total size of the split container (for pixel-to-ratio conversion)
412 container_size: u16,
413 /// Workspace index
414 workspace: usize,
415 },
416 }
417
418 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
419 pub enum ResizeEdge {
420 TopLeft,
421 Top,
422 TopRight,
423 Left,
424 Right,
425 BottomLeft,
426 Bottom,
427 BottomRight,
428 None,
429 }
430
431 impl WindowManager {
432 /// Set up initial keybinds and grabs from Lua config.
433 pub fn setup_grabs(&mut self) -> Result<()> {
434 let state = self.lua_state.lock().unwrap();
435 for keybind in &state.keybinds {
436 if let Some(keycode) = self.conn.keycode_from_keysym(keybind.keysym) {
437 self.conn.grab_key(keybind.modifiers, keycode)?;
438 tracing::debug!(
439 "Grabbed {:?}+keycode {} for {:?}",
440 keybind.modifiers,
441 keycode,
442 keybind.action
443 );
444 }
445 }
446
447 // Grab Alt+Button1/Button3 on root for floating window move/resize
448 self.conn.grab_mod_buttons()?;
449
450 // Grab Button1 on root (without mod) for edge resize in gaps between tiled windows
451 self.conn.grab_button1_on_root()?;
452
453 self.conn.flush()?;
454 tracing::info!("{} keybinds registered", state.keybinds.len());
455 Ok(())
456 }
457
458 /// Set up EWMH workspace hints for status bar integration.
459 pub fn setup_ewmh_hints(&self) -> Result<()> {
460 // Advertise supported EWMH atoms
461 self.conn.set_ewmh_supported()?;
462
463 // Create WM check window for EWMH identification
464 self.conn.setup_wm_check()?;
465
466 // Initialize empty client lists
467 self.conn.update_client_list(&[])?;
468 self.conn.update_client_list_stacking(&[])?;
469
470 // Set number of desktops
471 let num_desktops = self.workspaces.len() as u32;
472 self.conn.set_number_of_desktops(num_desktops)?;
473
474 // Set desktop names
475 let names: Vec<String> = self.workspaces.iter().map(|ws| ws.name.clone()).collect();
476 self.conn.set_desktop_names(&names)?;
477
478 // Set current desktop
479 self.conn.set_current_desktop(self.focused_workspace as u32)?;
480
481 // Set active window (none at startup)
482 self.conn.set_active_window(None)?;
483
484 self.conn.flush()?;
485 tracing::info!("EWMH workspace hints initialized: {} desktops", num_desktops);
486 Ok(())
487 }
488
489 /// Adopt any existing windows that were already mapped before we started.
490 pub fn adopt_existing_windows(&mut self) -> Result<()> {
491 use x11rb::protocol::xproto::MapState;
492
493 // Query children of root window
494 let reply = self.conn.conn.query_tree(self.conn.root)?.reply()?;
495 let mut adopted = 0;
496
497 for &window in &reply.children {
498 // Get window attributes to check if it's mapped and not override-redirect
499 let attrs = match self.conn.conn.get_window_attributes(window)?.reply() {
500 Ok(a) => a,
501 Err(_) => continue, // Window may have been destroyed
502 };
503
504 // Skip override-redirect windows (menus, tooltips, etc.)
505 if attrs.override_redirect {
506 continue;
507 }
508
509 // Only adopt currently mapped windows
510 if attrs.map_state != MapState::VIEWABLE {
511 continue;
512 }
513
514 // Skip if we already manage this window
515 if self.windows.contains_key(&window) {
516 continue;
517 }
518
519 // Check window rules and EWMH hints
520 let rule_actions = self.check_rules(window);
521 let should_float = rule_actions.floating.unwrap_or_else(|| self.conn.should_float(window));
522
523 // Subscribe to events on the window
524 // All windows get POINTER_MOTION for edge resize cursor feedback
525 let events = EventMask::ENTER_WINDOW
526 | EventMask::FOCUS_CHANGE
527 | EventMask::PROPERTY_CHANGE
528 | EventMask::STRUCTURE_NOTIFY
529 | EventMask::POINTER_MOTION;
530 self.conn.select_input(window, events)?;
531
532 // Grab button for click-to-focus
533 self.conn.grab_button(window)?;
534
535 // Manage the window
536 if should_float {
537 self.manage_window_floating(window);
538 } else {
539 self.manage_window(window);
540 }
541 adopted += 1;
542 }
543
544 if adopted > 0 {
545 self.apply_layout()?;
546 // Focus the first window
547 if let Some(window) = self.focused_window {
548 self.set_focus(window, true)?;
549 }
550 tracing::info!("Adopted {} existing windows", adopted);
551 }
552
553 Ok(())
554 }
555
556 pub fn handle_event(&mut self, event: Event) -> Result<()> {
557 match event {
558 Event::MapRequest(e) => self.handle_map_request(e)?,
559 Event::ConfigureRequest(e) => self.handle_configure_request(e)?,
560 Event::UnmapNotify(e) => self.handle_unmap_notify(e)?,
561 Event::DestroyNotify(e) => self.handle_destroy_notify(e)?,
562 Event::ButtonPress(e) => self.handle_button_press(e)?,
563 Event::ButtonRelease(e) => self.handle_button_release(e)?,
564 Event::MotionNotify(e) => {
565 // Log first motion event to confirm events are arriving
566 use std::sync::atomic::{AtomicBool, Ordering};
567 static FIRST_MOTION: AtomicBool = AtomicBool::new(true);
568 if FIRST_MOTION.swap(false, Ordering::Relaxed) {
569 debug_log(&format!("FIRST_MOTION_EVENT: window={}", e.event));
570 }
571 self.handle_motion_notify(e)?
572 }
573 Event::KeyPress(e) => self.handle_key_press(e)?,
574 Event::EnterNotify(e) => {
575 self.handle_enter_notify(e)?;
576 }
577 Event::RandrScreenChangeNotify(e) => {
578 tracing::info!(
579 "RandR screen change: {}x{} -> {}x{}",
580 self.conn.screen_width, self.conn.screen_height,
581 e.width, e.height
582 );
583 // Update cached screen dimensions from the event
584 self.conn.screen_width = e.width;
585 self.conn.screen_height = e.height;
586 self.refresh_monitors()?;
587 self.broadcast_i3_output_event();
588 }
589 Event::RandrNotify(_) => {
590 tracing::info!("RandR notify event, refreshing monitors");
591 self.refresh_monitors()?;
592 self.broadcast_i3_output_event();
593 }
594 Event::ClientMessage(e) => {
595 self.handle_client_message(e)?;
596 }
597 Event::PropertyNotify(e) => {
598 self.handle_property_notify(e)?;
599 }
600 Event::Expose(e) => {
601 self.handle_expose(e)?;
602 }
603 _ => {
604 tracing::trace!("Unhandled event: {:?}", event);
605 }
606 }
607 Ok(())
608 }
609
610 fn handle_map_request(&mut self, event: MapRequestEvent) -> Result<()> {
611 let window = event.window;
612 tracing::debug!("MapRequest for window {}", window);
613
614 // Check for dock/desktop windows (polybar, etc.) - don't manage, just map
615 if self.conn.should_ignore(window) {
616 tracing::info!("Window {} is dock/desktop, mapping without managing", window);
617 self.conn.map_window(window)?;
618
619 // Read struts from dock windows (reserved screen areas)
620 if let Some(strut) = self.conn.get_strut(window) {
621 tracing::info!(
622 "Dock window {} has strut: left={}, right={}, top={}, bottom={}",
623 window, strut.left, strut.right, strut.top, strut.bottom
624 );
625 self.dock_struts.insert(window, strut);
626 // Re-apply layout to respect new strut
627 self.apply_layout()?;
628 }
629
630 self.conn.flush()?;
631 return Ok(());
632 }
633
634 // Check if we should manage this window
635 if !self.should_manage(window) {
636 // Just map it without managing
637 self.conn.map_window(window)?;
638 self.conn.flush()?;
639 return Ok(());
640 }
641
642 // Check window rules first
643 let rule_actions = self.check_rules(window);
644
645 // Determine target workspace: rule > mouse position > focused
646 // This makes windows spawn on the monitor where the mouse is
647 let target_idx = if let Some(ws) = rule_actions.workspace {
648 // Rule specifies workspace (1-indexed)
649 ws.saturating_sub(1).min(self.workspaces.len() - 1)
650 } else {
651 // Use workspace of monitor under mouse pointer
652 self.workspace_for_new_window()
653 };
654
655 // Determine if window should float (rule > ICCCM/EWMH hints)
656 let should_float = rule_actions.floating.unwrap_or_else(|| self.conn.should_float(window));
657
658 // Subscribe to events on the window
659 // All windows get POINTER_MOTION for edge resize cursor feedback
660 let events = EventMask::ENTER_WINDOW
661 | EventMask::FOCUS_CHANGE
662 | EventMask::PROPERTY_CHANGE
663 | EventMask::STRUCTURE_NOTIFY
664 | EventMask::POINTER_MOTION;
665 self.conn.select_input(window, events)?;
666
667 // Grab button for click-to-focus
668 self.conn.grab_button(window)?;
669
670 // Manage window on target workspace
671 let target_visible = self.is_workspace_visible(target_idx);
672
673 if should_float {
674 if target_idx == self.focused_workspace {
675 self.manage_window_floating(window);
676 } else {
677 self.manage_window_floating_on_workspace(window, target_idx);
678 }
679 } else {
680 if target_idx == self.focused_workspace {
681 self.manage_window(window);
682 } else {
683 self.manage_window_on_workspace(window, target_idx);
684 }
685 }
686
687 // Create frame if title bars enabled
688 let frame = self.create_frame_for_window(window);
689
690 // Map window if it's on a visible workspace (any monitor)
691 if target_visible {
692 if frame.is_some() {
693 self.frames.map_frame(&self.conn.conn, window)?;
694 }
695 self.conn.map_window(window)?;
696 }
697
698 // Apply layout to all windows
699 self.apply_layout()?;
700 // Flush to ensure ConfigureWindow requests are processed before we query geometry
701 self.conn.flush()?;
702
703 // Focus and raise the new window if on a visible workspace
704 if target_visible {
705 self.set_focus(window, true)?;
706 self.raise_window(window)?;
707 }
708
709 Ok(())
710 }
711
712 fn handle_configure_request(&mut self, event: ConfigureRequestEvent) -> Result<()> {
713 tracing::trace!("ConfigureRequest for window {}", event.window);
714
715 // If we're not managing this window, pass through the request
716 if !self.windows.contains_key(&event.window) {
717 let aux = ConfigureWindowAux::from_configure_request(&event);
718 self.conn.conn.configure_window(event.window, &aux)?;
719 self.conn.flush()?;
720 }
721 // If we are managing it, we control its geometry via apply_layout()
722
723 Ok(())
724 }
725
726 fn handle_unmap_notify(&mut self, event: UnmapNotifyEvent) -> Result<()> {
727 let window = event.window;
728 tracing::debug!("UnmapNotify for window {} (event on {})", window, event.event);
729
730 // Only handle SubstructureNotify events (event.event == root)
731 // Ignore StructureNotify events sent directly to the window (event.event == window)
732 // This prevents double-processing since we get both types of events
733 if event.event != self.conn.root {
734 tracing::debug!("Ignoring UnmapNotify (not from root, likely StructureNotify)");
735 return Ok(());
736 }
737
738 // Check if this was a dock window with struts
739 if self.dock_struts.remove(&window).is_some() {
740 tracing::info!("Dock window {} unmapped, removing strut", window);
741 self.apply_layout()?;
742 self.conn.flush()?;
743 return Ok(());
744 }
745
746 // Check if we intentionally unmapped this window (workspace switch, move, etc.)
747 // If so, decrement the counter and ignore this UnmapNotify
748 if let Some(win) = self.windows.get_mut(&window) {
749 if win.ignore_unmap_count > 0 {
750 win.ignore_unmap_count -= 1;
751 tracing::debug!("Ignoring UnmapNotify for window {} (intentional unmap, count now {})",
752 window, win.ignore_unmap_count);
753 return Ok(());
754 }
755 }
756
757 // Check if this window is on a visible workspace (any monitor's active workspace)
758 let is_visible = self.windows.get(&window)
759 .map(|w| self.is_workspace_visible(w.workspace))
760 .unwrap_or(false);
761
762 // Only unmanage if the window was visible - windows on hidden workspaces
763 // are unmapped intentionally by us during workspace switching
764 if is_visible {
765 self.unmanage_window(window);
766
767 // Clear the entire root window to remove any leftover pixels
768 self.conn.clear_root_area(0, 0, self.conn.screen_width, self.conn.screen_height)?;
769
770 self.apply_layout()?;
771
772 // Focus next window or warp to monitor if none left
773 if let Some(win) = self.focused_window {
774 self.set_focus(win, true)?;
775 } else {
776 // No windows left, warp to current monitor center
777 self.warp_to_monitor(self.focused_monitor)?;
778 }
779
780 self.conn.flush()?;
781 }
782
783 Ok(())
784 }
785
786 fn handle_destroy_notify(&mut self, event: DestroyNotifyEvent) -> Result<()> {
787 tracing::debug!("DestroyNotify for window {}", event.window);
788
789 // Clean up drag state if the destroyed window was involved in a drag
790 if let Some(ref drag) = self.drag_state {
791 let drag_window = match drag {
792 DragState::Move { window, .. } |
793 DragState::Resize { window, .. } |
794 DragState::TiledSwap { window, .. } |
795 DragState::TiledResize { window, .. } => *window,
796 };
797 if drag_window == event.window {
798 self.drag_state = None;
799 let _ = self.conn.ungrab_pointer();
800 }
801 }
802
803 // Check if this was a dock window with struts
804 if self.dock_struts.remove(&event.window).is_some() {
805 tracing::info!("Dock window {} destroyed, removing strut", event.window);
806 }
807
808 // Check if this window was actually managed before doing layout/focus work
809 let was_managed = self.windows.contains_key(&event.window);
810
811 // Remove from management
812 self.unmanage_window(event.window);
813
814 // Only do layout/focus work if the window was actually managed
815 // Unmanaged windows (like popup menus, tooltips) shouldn't trigger layout recalc or pointer warps
816 if was_managed {
817 // Clear the entire root window to remove any leftover pixels
818 // This is needed because X11 without a compositor doesn't automatically repaint
819 self.conn.clear_root_area(0, 0, self.conn.screen_width, self.conn.screen_height)?;
820
821 // Re-apply layout
822 self.apply_layout()?;
823
824 // Focus next window or warp to monitor if none left
825 if let Some(window) = self.focused_window {
826 self.set_focus(window, true)?;
827 } else {
828 // No windows left, warp to current monitor center
829 self.warp_to_monitor(self.focused_monitor)?;
830 }
831 }
832
833 // Always flush to ensure any pointer ungrab or other operations are sent
834 self.conn.flush()?;
835 Ok(())
836 }
837
838 fn handle_button_press(&mut self, event: ButtonPressEvent) -> Result<()> {
839 let event_window = event.event;
840 let child = event.child;
841 // Translate frame window to client window if needed
842 let window = self.frames.client_for_frame(event_window).unwrap_or(event_window);
843 debug_log(&format!("BUTTON_PRESS_START: event_window={}, window={}, child={}, button={}, state={:?}",
844 event_window, window, child, event.detail, event.state));
845 tracing::debug!("ButtonPress on window {} (event_window={}), child {}, button {}", window, event_window, child, event.detail);
846
847 // If we're already in a drag, ignore additional button presses
848 if self.drag_state.is_some() {
849 tracing::debug!("Already in drag state, ignoring ButtonPress");
850 return Ok(());
851 }
852
853 // Check for mod+click on floating windows (move/resize)
854 // Support both Alt (MOD1) and Super (MOD4) as the modifier
855 let has_mod = event.state.contains(x11rb::protocol::xproto::KeyButMask::MOD1)
856 || event.state.contains(x11rb::protocol::xproto::KeyButMask::MOD4);
857
858 // For Alt+click from root grab, use child window (the window under cursor)
859 let target = if window == self.conn.root && child != 0 {
860 child
861 } else {
862 window
863 };
864
865 if has_mod && self.is_floating(target) {
866 let geometry = self.get_floating_geometry(target);
867
868 if event.detail == 1 {
869 // Mod+Button1 = Move
870 tracing::debug!("Starting move for floating window {}", target);
871 self.drag_state = Some(DragState::Move {
872 window: target,
873 start_x: event.root_x,
874 start_y: event.root_y,
875 start_geometry: geometry,
876 });
877 // Clear window cursor to prevent conflict with grab cursor
878 self.conn.clear_window_cursor(target)?;
879 // Grab pointer for motion events (use fleur/move cursor)
880 self.conn.grab_pointer(Some(self.conn.cursor_move))?;
881 return Ok(());
882 } else if event.detail == 3 {
883 // Mod+Button3 = Resize (quadrant-based edge detection)
884 let edge = determine_resize_edge_quadrant(&geometry, event.root_x, event.root_y);
885 tracing::debug!("Starting resize for floating window {}, edge {:?}", target, edge);
886 self.drag_state = Some(DragState::Resize {
887 window: target,
888 start_x: event.root_x,
889 start_y: event.root_y,
890 start_geometry: geometry,
891 edge,
892 });
893 // Clear window cursor to prevent conflict with grab cursor
894 self.conn.clear_window_cursor(target)?;
895 let cursor = self.cursor_for_edge(edge);
896 self.conn.grab_pointer(Some(cursor))?;
897 return Ok(());
898 }
899 }
900
901 // Mod+Button1 on TILED window = swap drag
902 if has_mod && !self.is_floating(target) && self.windows.contains_key(&target) && event.detail == 1 {
903 if self.current_workspace().tree.contains(target) {
904 let screen = self.screen_rect();
905 let tiled_geometries = self.current_workspace()
906 .tree
907 .calculate_geometries(screen);
908 let original_tree = self.current_workspace().tree.clone();
909
910 // Calculate grab offset from window origin for smooth dragging
911 let window_geom = tiled_geometries.iter()
912 .find(|(w, _)| *w == target)
913 .map(|(_, r)| *r)
914 .unwrap_or_default();
915 let grab_offset_x = event.root_x - window_geom.x;
916 let grab_offset_y = event.root_y - window_geom.y;
917
918 tracing::debug!("Starting tiled swap drag for window {}, offset=({},{})",
919 target, grab_offset_x, grab_offset_y);
920 self.drag_state = Some(DragState::TiledSwap {
921 window: target,
922 grab_offset_x,
923 grab_offset_y,
924 original_tree,
925 tiled_geometries,
926 hover_target: None,
927 workspace: self.focused_workspace,
928 });
929 // Raise the dragged window above others
930 self.conn.raise_window(target)?;
931 self.conn.grab_pointer(Some(self.conn.cursor_move))?;
932 return Ok(());
933 }
934 }
935
936 // Check for edge resize on TILED windows - detect edge at click time
937 // This works even for apps like alacritty that don't propagate motion events
938 let is_managed_window = self.windows.contains_key(&window);
939 let is_managed_target = self.windows.contains_key(&target);
940 let is_window_float = is_managed_window && self.is_floating(window);
941 let is_target_float = is_managed_target && self.is_floating(target);
942 debug_log(&format!("TILED GATE: has_mod={}, button={}, window={}, target={}, is_managed_window={}, is_managed_target={}, is_window_float={}, is_target_float={}",
943 has_mod, event.detail, window, target, is_managed_window, is_managed_target, is_window_float, is_target_float));
944
945 // Use target (like tiled swap does) when window is root with child, otherwise window
946 let resize_window = if is_managed_target && !is_target_float {
947 target
948 } else if is_managed_window && !is_window_float {
949 window
950 } else {
951 // Neither is a valid tiled window, skip
952 0
953 };
954
955 if !has_mod && event.detail == 1 {
956 let work_area = self.work_area();
957 let geometries = self.current_workspace().tree.calculate_geometries(work_area);
958
959 debug_log(&format!("TILED EDGE CHECK: resize_window={}, pos=({},{}), num_geom={}",
960 resize_window, event.root_x, event.root_y, geometries.len()));
961
962 // Try to find edge resize - either from clicked window or from gap click
963 let edge_result = if resize_window != 0 {
964 // Clicked on a tiled window - check its edges
965 if let Some((_, my_rect)) = geometries.iter().find(|(w, _)| *w == resize_window) {
966 let my_rect = *my_rect;
967 debug_log(&format!("TILED EDGE CHECK: my_rect={:?}", my_rect));
968 self.find_tiled_resize_edge(resize_window, &my_rect, event.root_x, event.root_y, &geometries)
969 } else {
970 None
971 }
972 } else {
973 // Clicked on root/gap - find any adjacent windows near click position
974 debug_log(&format!("GAP CLICK: checking {} geometries for edge near ({},{})",
975 geometries.len(), event.root_x, event.root_y));
976 self.find_edge_from_gap(event.root_x, event.root_y, &geometries)
977 };
978
979 if let Some((w1, w2, direction)) = edge_result {
980 debug_log(&format!("TILED EDGE FOUND: w1={}, w2={}, dir={:?}", w1, w2, direction));
981
982 // Calculate container size from both windows
983 let rect1 = geometries.iter().find(|(w, _)| *w == w1).map(|(_, r)| r);
984 let rect2 = geometries.iter().find(|(w, _)| *w == w2).map(|(_, r)| r);
985 let container_size = match (rect1, rect2) {
986 (Some(r1), Some(r2)) => match direction {
987 Direction::Left | Direction::Right => r1.width + r2.width,
988 Direction::Up | Direction::Down => r1.height + r2.height,
989 },
990 _ => match direction {
991 Direction::Left | Direction::Right => work_area.width,
992 Direction::Up | Direction::Down => work_area.height,
993 },
994 };
995
996 // Get current ratio from tree (use w1 which is the "left/top" window)
997 let start_ratio = self
998 .current_workspace()
999 .tree
1000 .get_split_ratio(w1, direction)
1001 .unwrap_or(0.5);
1002
1003 let start_pos = match direction {
1004 Direction::Left | Direction::Right => event.root_x,
1005 Direction::Up | Direction::Down => event.root_y,
1006 };
1007
1008 debug_log(&format!("STARTING TILED RESIZE: w1={}, dir={:?}, ratio={}, container={}", w1, direction, start_ratio, container_size));
1009 self.drag_state = Some(DragState::TiledResize {
1010 direction,
1011 start_pos,
1012 start_ratio,
1013 window: w1,
1014 container_size,
1015 workspace: self.focused_workspace,
1016 });
1017
1018 let cursor = match direction {
1019 Direction::Left | Direction::Right => self.conn.cursor_h_double,
1020 Direction::Up | Direction::Down => self.conn.cursor_v_double,
1021 };
1022 self.conn.grab_pointer(Some(cursor))?;
1023 return Ok(());
1024 }
1025
1026 // If we clicked on the gap (root) but didn't find an edge, replay the event
1027 if resize_window == 0 {
1028 debug_log("GAP CLICK: no edge found, replaying event");
1029 self.conn.conn.allow_events(
1030 x11rb::protocol::xproto::Allow::REPLAY_POINTER,
1031 x11rb::CURRENT_TIME,
1032 )?;
1033 self.conn.flush()?;
1034 return Ok(());
1035 }
1036 }
1037
1038 // Check for edge resize on floating windows (click on edge without mod key)
1039 let is_floating_win = self.is_floating(window);
1040 tracing::debug!(
1041 "Edge resize check: window={}, is_floating={}, button={}, pos=({},{})",
1042 window, is_floating_win, event.detail, event.root_x, event.root_y
1043 );
1044
1045 if !has_mod && is_floating_win && event.detail == 1 {
1046 let geometry = self.get_floating_geometry(window);
1047 let edge = determine_resize_edge(&geometry, event.root_x, event.root_y);
1048 tracing::debug!(
1049 "Edge detection: geometry=({},{} {}x{}), edge={:?}",
1050 geometry.x, geometry.y, geometry.width, geometry.height, edge
1051 );
1052 if edge != ResizeEdge::None {
1053 tracing::info!("Starting edge resize for floating window {}, edge {:?}", window, edge);
1054 self.drag_state = Some(DragState::Resize {
1055 window,
1056 start_x: event.root_x,
1057 start_y: event.root_y,
1058 start_geometry: geometry.clone(),
1059 edge,
1060 });
1061 tracing::debug!("Grabbing pointer for resize, geometry={:?}", geometry);
1062 // Clear window cursor to prevent conflict with grab cursor
1063 self.conn.clear_window_cursor(window)?;
1064 let cursor = self.cursor_for_edge(edge);
1065 self.conn.grab_pointer(Some(cursor))?;
1066 self.conn.flush()?;
1067 return Ok(());
1068 }
1069 // Not on edge - if this is a focused floating window, raise it and replay the click
1070 // This ensures clicking on a floating window that somehow ended up behind
1071 // other windows will bring it to the front
1072 if self.focused_window == Some(window) {
1073 self.raise_window(window)?;
1074 self.conn.conn.allow_events(
1075 x11rb::protocol::xproto::Allow::REPLAY_POINTER,
1076 x11rb::CURRENT_TIME,
1077 )?;
1078 self.conn.flush()?;
1079 return Ok(());
1080 }
1081 }
1082
1083 // Only handle if we manage this window
1084 if !self.windows.contains_key(&window) {
1085 // Replay the click so it passes through to the unmanaged window
1086 self.conn.conn.allow_events(
1087 x11rb::protocol::xproto::Allow::REPLAY_POINTER,
1088 x11rb::CURRENT_TIME,
1089 )?;
1090 self.conn.flush()?;
1091 return Ok(());
1092 }
1093
1094 // Focus the clicked window
1095 if self.focused_window != Some(window) {
1096 // Regrab button on old focused window for click-to-focus
1097 if let Some(old) = self.focused_window {
1098 self.conn.grab_button(old)?;
1099 }
1100
1101 // Set focus
1102 self.set_focus(window, false)?;
1103
1104 // Ungrab buttons so clicks pass through to the application
1105 // Edge resize detection uses POINTER_MOTION events, not button grabs
1106 self.conn.ungrab_button(window)?;
1107
1108 // Raise floating windows on focus
1109 if self.is_floating(window) {
1110 self.raise_window(window)?;
1111 }
1112
1113 // Replay the click so the application receives it
1114 self.conn.conn.allow_events(
1115 x11rb::protocol::xproto::Allow::REPLAY_POINTER,
1116 x11rb::CURRENT_TIME,
1117 )?;
1118 }
1119
1120 self.conn.flush()?;
1121 Ok(())
1122 }
1123
1124 fn handle_button_release(&mut self, event: ButtonReleaseEvent) -> Result<()> {
1125 tracing::debug!("ButtonRelease: button={}, window={}, in_drag={}",
1126 event.detail, event.event, self.drag_state.is_some());
1127 if let Some(drag) = self.drag_state.take() {
1128 tracing::debug!("Ending drag operation");
1129 self.conn.ungrab_pointer()?;
1130
1131 match drag {
1132 DragState::TiledSwap { window, hover_target, workspace, original_tree, .. } => {
1133 // Restore target border color
1134 if let Some(target) = hover_target {
1135 let color = if self.focused_window == Some(target) {
1136 self.config.border_color_focused
1137 } else {
1138 self.config.border_color_unfocused
1139 };
1140 self.conn.set_border(target, self.config.border_width, color)?;
1141 }
1142
1143 // Finalize: snap window to its layout position
1144 if workspace == self.focused_workspace {
1145 // Swaps already happened live during motion
1146 // Just re-apply layout to snap dragged window to final position
1147 self.apply_layout()?;
1148 self.set_focus(window, true)?;
1149 } else {
1150 // Workspace changed during drag - revert to original
1151 self.workspaces[workspace].tree = original_tree;
1152 if workspace == self.focused_workspace {
1153 self.apply_layout()?;
1154 }
1155 }
1156 }
1157 DragState::Move { window, .. } | DragState::Resize { window, .. } => {
1158 // Restore edge cursor if pointer is still over the dragged floating window
1159 if self.is_floating(window) {
1160 self.update_edge_cursor(window, event.root_x, event.root_y)?;
1161 }
1162 }
1163 DragState::TiledResize { .. } => {
1164 // Layout already applied during motion
1165 // Reset root cursor to default
1166 self.conn.set_root_cursor(self.conn.cursor_normal)?;
1167 // Allow any frozen pointer events to proceed
1168 self.conn.conn.allow_events(
1169 x11rb::protocol::xproto::Allow::ASYNC_POINTER,
1170 x11rb::CURRENT_TIME,
1171 )?;
1172 }
1173 }
1174
1175 self.conn.flush()?;
1176 }
1177 Ok(())
1178 }
1179
1180 fn handle_motion_notify(&mut self, event: MotionNotifyEvent) -> Result<()> {
1181 // Log first few motion events
1182 use std::sync::atomic::{AtomicU32, Ordering};
1183 static HANDLER_COUNT: AtomicU32 = AtomicU32::new(0);
1184 let count = HANDLER_COUNT.fetch_add(1, Ordering::Relaxed);
1185 if count < 5 {
1186 debug_log(&format!("MOTION_HANDLER: count={}, event_window={}, drag_state={}",
1187 count, event.event, self.drag_state.is_some()));
1188 }
1189
1190 // If not in a drag, check for edge cursor changes
1191 if self.drag_state.is_none() {
1192 let event_window = event.event;
1193 // Translate frame window to client window if needed
1194 let window = self.frames.client_for_frame(event_window).unwrap_or(event_window);
1195
1196 let in_windows = self.windows.contains_key(&window);
1197 let is_floating = in_windows && self.is_floating(window);
1198
1199 // Log occasionally (every ~100 events to avoid spam)
1200 use std::sync::atomic::{AtomicU32, Ordering};
1201 static MOTION_COUNT: AtomicU32 = AtomicU32::new(0);
1202 let mc = MOTION_COUNT.fetch_add(1, Ordering::Relaxed);
1203 if mc % 100 == 0 {
1204 debug_log(&format!("MOTION: event_win={}, client_win={}, in_windows={}, is_floating={}, pos=({},{})",
1205 event_window, window, in_windows, is_floating, event.root_x, event.root_y));
1206 }
1207
1208 if in_windows {
1209 if is_floating {
1210 self.update_edge_cursor(window, event.root_x, event.root_y)?;
1211 } else {
1212 // Check for tiled edge hover (for cursor feedback)
1213 self.update_tiled_edge_cursor(window, event.root_x, event.root_y)?;
1214 }
1215 } else if self.tiled_edge_cursor.is_some() {
1216 // Moving to unmanaged window or root - clear tiled edge cursor
1217 let (old_w1, old_w2, _) = self.tiled_edge_cursor.unwrap();
1218 self.conn.clear_window_cursor(old_w1)?;
1219 self.conn.clear_window_cursor(old_w2)?;
1220 self.conn.flush()?;
1221 self.tiled_edge_cursor = None;
1222 }
1223 return Ok(());
1224 }
1225
1226 let drag = self.drag_state.as_ref().unwrap();
1227 tracing::debug!("Motion during drag: root({},{}), drag_state={:?}", event.root_x, event.root_y, drag);
1228 match drag {
1229 DragState::Move {
1230 window,
1231 start_x,
1232 start_y,
1233 start_geometry,
1234 } => {
1235 let dx = event.root_x - start_x;
1236 let dy = event.root_y - start_y;
1237 let new_x = start_geometry.x + dx;
1238 let new_y = start_geometry.y + dy;
1239
1240 let window = *window;
1241 self.set_floating_position(window, new_x, new_y)?;
1242 }
1243 DragState::Resize {
1244 window,
1245 start_x,
1246 start_y,
1247 start_geometry,
1248 edge,
1249 } => {
1250 let dx = event.root_x - start_x;
1251 let dy = event.root_y - start_y;
1252
1253 let (new_x, new_y, new_w, new_h) = calculate_resize(
1254 start_geometry,
1255 *edge,
1256 dx,
1257 dy,
1258 );
1259
1260 let window = *window;
1261 self.set_floating_geometry(window, new_x, new_y, new_w, new_h)?;
1262 }
1263 DragState::TiledSwap {
1264 window,
1265 grab_offset_x,
1266 grab_offset_y,
1267 tiled_geometries,
1268 hover_target,
1269 workspace,
1270 ..
1271 } => {
1272 // Only process if still on same workspace
1273 if *workspace != self.focused_workspace {
1274 return Ok(());
1275 }
1276
1277 let cursor_x = event.root_x;
1278 let cursor_y = event.root_y;
1279 let dragged = *window;
1280 let old_target = *hover_target;
1281 let grab_offset_x = *grab_offset_x;
1282 let grab_offset_y = *grab_offset_y;
1283
1284 // Move dragged window to follow cursor using fixed grab offset
1285 let new_x = cursor_x - grab_offset_x;
1286 let new_y = cursor_y - grab_offset_y;
1287 self.conn.move_window(dragged, new_x, new_y)?;
1288 self.conn.flush()?;
1289
1290 // Find window under cursor (excluding dragged window)
1291 let new_target = tiled_geometries.iter()
1292 .find(|(w, rect)| {
1293 *w != dragged &&
1294 cursor_x >= rect.x && cursor_x < rect.x + rect.width as i16 &&
1295 cursor_y >= rect.y && cursor_y < rect.y + rect.height as i16
1296 })
1297 .map(|(w, _)| *w);
1298
1299 // Perform live swap if target changed
1300 if new_target != old_target {
1301 // Restore old target border
1302 if let Some(old) = old_target {
1303 let color = if self.focused_window == Some(old) {
1304 self.config.border_color_focused
1305 } else {
1306 self.config.border_color_unfocused
1307 };
1308 self.conn.set_border(old, self.config.border_width, color)?;
1309 }
1310
1311 // Perform actual swap if new target exists
1312 if let Some(target) = new_target {
1313 // Swap in tree and relayout (live preview)
1314 self.current_workspace_mut().tree.swap(dragged, target);
1315 self.apply_layout()?;
1316
1317 // Re-raise dragged window above others
1318 self.conn.raise_window(dragged)?;
1319
1320 // Highlight new target
1321 self.conn.set_border(target, self.config.border_width,
1322 self.config.border_color_swap_target)?;
1323
1324 // Update cached geometries after swap
1325 let screen = self.screen_rect();
1326 let new_geometries = self.current_workspace()
1327 .tree
1328 .calculate_geometries(screen);
1329 if let Some(DragState::TiledSwap { tiled_geometries: tg, hover_target: ht, .. }) = &mut self.drag_state {
1330 *tg = new_geometries;
1331 *ht = new_target;
1332 }
1333 } else {
1334 // No new target, just update hover state
1335 if let Some(DragState::TiledSwap { hover_target: ht, .. }) = &mut self.drag_state {
1336 *ht = new_target;
1337 }
1338 }
1339 self.conn.flush()?;
1340 }
1341 }
1342 DragState::TiledResize {
1343 direction,
1344 start_pos,
1345 start_ratio,
1346 window,
1347 container_size,
1348 workspace,
1349 } => {
1350 if *workspace != self.focused_workspace {
1351 return Ok(());
1352 }
1353
1354 let current_pos = match direction {
1355 Direction::Left | Direction::Right => event.root_x,
1356 Direction::Up | Direction::Down => event.root_y,
1357 };
1358
1359 // Convert pixel delta to ratio delta
1360 let pixel_delta = current_pos - *start_pos;
1361 let ratio_delta = pixel_delta as f32 / *container_size as f32;
1362
1363 let new_ratio = (*start_ratio + ratio_delta).clamp(0.1, 0.9);
1364
1365 debug_log(&format!("TILED RESIZE MOTION: pixel_delta={}, new_ratio={}", pixel_delta, new_ratio));
1366
1367 // Update tree and apply layout
1368 let window = *window;
1369 let direction = *direction;
1370 let changed = self.current_workspace_mut()
1371 .tree
1372 .set_split_ratio(window, direction, new_ratio);
1373 debug_log(&format!("set_split_ratio returned: {}", changed));
1374 self.apply_layout()?;
1375 self.conn.flush()?;
1376 }
1377 }
1378
1379 Ok(())
1380 }
1381
1382 /// Handle pointer motion for edge cursor changes on floating windows.
1383 fn update_edge_cursor(&mut self, window: u32, root_x: i16, root_y: i16) -> Result<()> {
1384 if !self.is_floating(window) {
1385 // Not a floating window, clear any edge cursor state
1386 if let Some((old_window, old_edge)) = self.current_edge_cursor {
1387 if old_edge != ResizeEdge::None {
1388 self.conn.clear_window_cursor(old_window)?;
1389 }
1390 self.current_edge_cursor = None;
1391 }
1392 return Ok(());
1393 }
1394
1395 let geometry = self.get_floating_geometry(window);
1396 let edge = determine_resize_edge(&geometry, root_x, root_y);
1397
1398 // Check if we need to update the cursor
1399 let current = self.current_edge_cursor;
1400 if current.map(|(w, e)| (w, e)) == Some((window, edge)) {
1401 return Ok(()); // No change needed
1402 }
1403
1404 if edge != ResizeEdge::None {
1405 let cursor = match edge {
1406 ResizeEdge::TopLeft => self.conn.cursor_top_left,
1407 ResizeEdge::Top => self.conn.cursor_top,
1408 ResizeEdge::TopRight => self.conn.cursor_top_right,
1409 ResizeEdge::Left => self.conn.cursor_left,
1410 ResizeEdge::Right => self.conn.cursor_right,
1411 ResizeEdge::BottomLeft => self.conn.cursor_bottom_left,
1412 ResizeEdge::Bottom => self.conn.cursor_bottom,
1413 ResizeEdge::BottomRight => self.conn.cursor_bottom_right,
1414 ResizeEdge::None => unreachable!(),
1415 };
1416 self.conn.set_window_cursor(window, cursor)?;
1417 self.conn.flush()?;
1418 self.current_edge_cursor = Some((window, edge));
1419 } else if let Some((old_window, old_edge)) = current {
1420 if old_edge != ResizeEdge::None {
1421 self.conn.clear_window_cursor(old_window)?;
1422 self.conn.flush()?;
1423 }
1424 self.current_edge_cursor = Some((window, ResizeEdge::None));
1425 } else {
1426 self.current_edge_cursor = Some((window, ResizeEdge::None));
1427 }
1428
1429 Ok(())
1430 }
1431
1432 /// Update cursor when hovering near edges of tiled windows.
1433 /// Called with the window that received the motion event.
1434 fn update_tiled_edge_cursor(&mut self, window: u32, root_x: i16, root_y: i16) -> Result<()> {
1435 let work_area = self.work_area();
1436 let geometries = self.current_workspace().tree.calculate_geometries(work_area);
1437
1438 // Find the geometry of the window we're over
1439 let my_geometry = geometries.iter().find(|(w, _)| *w == window).map(|(_, r)| r);
1440
1441 // Log occasionally
1442 use std::sync::atomic::{AtomicU32, Ordering};
1443 static EDGE_CHECK_COUNT: AtomicU32 = AtomicU32::new(0);
1444 let ec = EDGE_CHECK_COUNT.fetch_add(1, Ordering::Relaxed);
1445 if ec % 100 == 0 {
1446 debug_log(&format!("EDGE_CHECK: window={}, my_geom={:?}, num_geometries={}, pos=({},{})",
1447 window, my_geometry, geometries.len(), root_x, root_y));
1448 }
1449
1450 let new_state = if let Some(my_rect) = my_geometry {
1451 // Check if we're near an edge of this window that has an adjacent window
1452 self.find_tiled_resize_edge(window, my_rect, root_x, root_y, &geometries)
1453 } else {
1454 None
1455 };
1456
1457 // Check if cursor state changed
1458 if new_state == self.tiled_edge_cursor {
1459 return Ok(()); // No change
1460 }
1461
1462 // Clear old cursor state (use frame windows if they exist)
1463 if let Some((old_w1, old_w2, _)) = self.tiled_edge_cursor {
1464 let frame1 = self.frames.frame_for_client(old_w1).unwrap_or(old_w1);
1465 let frame2 = self.frames.frame_for_client(old_w2).unwrap_or(old_w2);
1466 self.conn.clear_window_cursor(frame1)?;
1467 self.conn.clear_window_cursor(frame2)?;
1468 // Also clear on client windows in case they don't have frames
1469 if frame1 != old_w1 {
1470 self.conn.clear_window_cursor(old_w1)?;
1471 }
1472 if frame2 != old_w2 {
1473 self.conn.clear_window_cursor(old_w2)?;
1474 }
1475 }
1476
1477 if let Some((w1, w2, dir)) = new_state {
1478 debug_log(&format!("EDGE DETECTED: w1={}, w2={}, dir={:?}", w1, w2, dir));
1479 // Set resize cursor on both windows sharing the edge (and their frames)
1480 let cursor = match dir {
1481 Direction::Left | Direction::Right => self.conn.cursor_h_double,
1482 Direction::Up | Direction::Down => self.conn.cursor_v_double,
1483 };
1484 let frame1 = self.frames.frame_for_client(w1).unwrap_or(w1);
1485 let frame2 = self.frames.frame_for_client(w2).unwrap_or(w2);
1486 self.conn.set_window_cursor(frame1, cursor)?;
1487 self.conn.set_window_cursor(frame2, cursor)?;
1488 // Also set on client windows
1489 self.conn.set_window_cursor(w1, cursor)?;
1490 self.conn.set_window_cursor(w2, cursor)?;
1491 }
1492 self.conn.flush()?;
1493
1494 self.tiled_edge_cursor = new_state;
1495 Ok(())
1496 }
1497
1498 /// Find if cursor is near an edge of the given window that has an adjacent tiled window.
1499 /// Returns (this_window, adjacent_window, direction) if on a resizable edge.
1500 fn find_tiled_resize_edge(
1501 &self,
1502 window: u32,
1503 rect: &Rect,
1504 x: i16,
1505 y: i16,
1506 geometries: &[(u32, Rect)],
1507 ) -> Option<(u32, u32, Direction)> {
1508 // Edge zone for resize detection - wide enough to cover gap + some margin
1509 // This makes it easy to grab edges: click near the boundary between windows
1510 let gap = self.config.gap_inner as i16;
1511 let edge_zone = gap + 8; // Gap width plus comfortable margin
1512
1513 let left = rect.x;
1514 let right = rect.x + rect.width as i16;
1515 let top = rect.y;
1516 let bottom = rect.y + rect.height as i16;
1517
1518 // Check each edge - trigger if within edge_zone of the window boundary
1519 let near_left = x >= left && x < left + edge_zone;
1520 let near_right = x > right - edge_zone && x <= right;
1521 let near_top = y >= top && y < top + edge_zone;
1522 let near_bottom = y > bottom - edge_zone && y <= bottom;
1523
1524 // For each edge we're near, look for an adjacent window
1525 if near_left {
1526 // Look for window to our left
1527 for (other_w, other_r) in geometries {
1528 if *other_w == window {
1529 continue;
1530 }
1531 let other_right = other_r.x + other_r.width as i16;
1532 // Check if other window's right edge is adjacent to our left edge
1533 if (other_right - left).abs() <= gap + 4 {
1534 // Check vertical overlap
1535 let y_overlap = y >= other_r.y.max(top) && y < (other_r.y + other_r.height as i16).min(bottom);
1536 if y_overlap {
1537 return Some((*other_w, window, Direction::Right));
1538 }
1539 }
1540 }
1541 }
1542
1543 if near_right {
1544 // Look for window to our right
1545 for (other_w, other_r) in geometries {
1546 if *other_w == window {
1547 continue;
1548 }
1549 // Check if other window's left edge is adjacent to our right edge
1550 if (other_r.x - right).abs() <= gap + 4 {
1551 // Check vertical overlap
1552 let y_overlap = y >= other_r.y.max(top) && y < (other_r.y + other_r.height as i16).min(bottom);
1553 if y_overlap {
1554 return Some((window, *other_w, Direction::Right));
1555 }
1556 }
1557 }
1558 }
1559
1560 if near_top {
1561 // Look for window above us
1562 for (other_w, other_r) in geometries {
1563 if *other_w == window {
1564 continue;
1565 }
1566 let other_bottom = other_r.y + other_r.height as i16;
1567 // Check if other window's bottom edge is adjacent to our top edge
1568 if (other_bottom - top).abs() <= gap + 4 {
1569 // Check horizontal overlap
1570 let x_overlap = x >= other_r.x.max(left) && x < (other_r.x + other_r.width as i16).min(right);
1571 if x_overlap {
1572 return Some((*other_w, window, Direction::Down));
1573 }
1574 }
1575 }
1576 }
1577
1578 if near_bottom {
1579 // Look for window below us
1580 for (other_w, other_r) in geometries {
1581 if *other_w == window {
1582 continue;
1583 }
1584 // Check if other window's top edge is adjacent to our bottom edge
1585 if (other_r.y - bottom).abs() <= gap + 4 {
1586 // Check horizontal overlap
1587 let x_overlap = x >= other_r.x.max(left) && x < (other_r.x + other_r.width as i16).min(right);
1588 if x_overlap {
1589 return Some((window, *other_w, Direction::Down));
1590 }
1591 }
1592 }
1593 }
1594
1595 None
1596 }
1597
1598 /// Find a resize edge when clicking in the gap between tiled windows.
1599 /// Returns (left/top_window, right/bottom_window, direction) if click is in a gap.
1600 fn find_edge_from_gap(
1601 &self,
1602 x: i16,
1603 y: i16,
1604 geometries: &[(u32, Rect)],
1605 ) -> Option<(u32, u32, Direction)> {
1606 let gap = self.config.gap_inner as i16;
1607
1608 // Log all geometries for debugging
1609 for (w, r) in geometries {
1610 debug_log(&format!("GAP CHECK GEOM: w={}, x={}, y={}, w={}, h={}, right={}, bottom={}",
1611 w, r.x, r.y, r.width, r.height, r.x + r.width as i16, r.y + r.height as i16));
1612 }
1613
1614 // Check all pairs of windows for horizontal adjacency (vertical split line)
1615 for (w1, r1) in geometries {
1616 let r1_right = r1.x + r1.width as i16;
1617 for (w2, r2) in geometries {
1618 if w1 == w2 {
1619 continue;
1620 }
1621 // Check if w2 is to the right of w1
1622 let horizontal_gap = r2.x - r1_right;
1623 debug_log(&format!("GAP H CHECK: w1={} right={}, w2={} left={}, gap={}, click_x={}",
1624 w1, r1_right, w2, r2.x, horizontal_gap, x));
1625
1626 // Allow detection if click is anywhere near the gap area
1627 // Gap region is from r1_right to r2.x, but expand by a few pixels for tolerance
1628 let gap_left = r1_right - 4;
1629 let gap_right = r2.x + 4;
1630
1631 if horizontal_gap >= 0 && horizontal_gap <= gap + 16 {
1632 if x >= gap_left && x <= gap_right {
1633 // Check vertical overlap at click position
1634 let v_overlap_top = r1.y.max(r2.y);
1635 let v_overlap_bottom = (r1.y + r1.height as i16).min(r2.y + r2.height as i16);
1636 debug_log(&format!("GAP H Y CHECK: y={}, v_top={}, v_bottom={}", y, v_overlap_top, v_overlap_bottom));
1637 if y >= v_overlap_top && y < v_overlap_bottom {
1638 debug_log(&format!("GAP EDGE FOUND H: w1={}, w2={}, gap_x=[{},{}], y_range=[{},{}]",
1639 w1, w2, r1_right, r2.x, v_overlap_top, v_overlap_bottom));
1640 return Some((*w1, *w2, Direction::Right));
1641 }
1642 }
1643 }
1644 }
1645 }
1646
1647 // Check all pairs of windows for vertical adjacency (horizontal split line)
1648 for (w1, r1) in geometries {
1649 let r1_bottom = r1.y + r1.height as i16;
1650 for (w2, r2) in geometries {
1651 if w1 == w2 {
1652 continue;
1653 }
1654 // Check if w2 is below w1
1655 let vertical_gap = r2.y - r1_bottom;
1656
1657 // Allow detection if click is anywhere near the gap area
1658 let gap_top = r1_bottom - 4;
1659 let gap_bottom = r2.y + 4;
1660
1661 if vertical_gap >= 0 && vertical_gap <= gap + 16 {
1662 if y >= gap_top && y <= gap_bottom {
1663 // Check horizontal overlap at click position
1664 let h_overlap_left = r1.x.max(r2.x);
1665 let h_overlap_right = (r1.x + r1.width as i16).min(r2.x + r2.width as i16);
1666 if x >= h_overlap_left && x < h_overlap_right {
1667 debug_log(&format!("GAP EDGE FOUND V: w1={}, w2={}, gap_y=[{},{}], x_range=[{},{}]",
1668 w1, w2, r1_bottom, r2.y, h_overlap_left, h_overlap_right));
1669 return Some((*w1, *w2, Direction::Down));
1670 }
1671 }
1672 }
1673 }
1674 }
1675
1676 None
1677 }
1678
1679 fn handle_enter_notify(&mut self, event: EnterNotifyEvent) -> Result<()> {
1680 let window = event.event;
1681
1682 // Ignore if we're in a drag operation
1683 if self.drag_state.is_some() {
1684 return Ok(());
1685 }
1686
1687 // Suppress EnterNotify events that happen shortly after a pointer warp
1688 // This prevents feedback loops from mouse-follows-focus
1689 if self.last_warp.elapsed() < std::time::Duration::from_millis(50) {
1690 return Ok(());
1691 }
1692
1693 // Ignore inferior (entering from a child window) and non-normal modes
1694 // Only handle "Normal" mode enters (actual mouse movement)
1695 if event.mode != NotifyMode::NORMAL {
1696 return Ok(());
1697 }
1698
1699 // Only focus windows we manage
1700 if !self.windows.contains_key(&window) {
1701 return Ok(());
1702 }
1703
1704 // Don't focus if already focused
1705 if self.focused_window == Some(window) {
1706 return Ok(());
1707 }
1708
1709 tracing::debug!("Focus follows mouse: focusing window {}", window);
1710
1711 // Focus the new window (no warp - mouse enter)
1712 // set_focus handles grab/ungrab for old and new windows
1713 self.set_focus(window, false)?;
1714
1715 // Raise floating windows on focus
1716 if self.is_floating(window) {
1717 self.raise_window(window)?;
1718 }
1719
1720 self.conn.flush()?;
1721 Ok(())
1722 }
1723
1724 /// Handle EWMH client message requests (focus, workspace switch, close, state changes).
1725 fn handle_client_message(&mut self, event: ClientMessageEvent) -> Result<()> {
1726 let msg_type = event.type_;
1727 let window = event.window;
1728
1729 if msg_type == self.conn.net_active_window {
1730 // Application requesting focus
1731 tracing::debug!("ClientMessage: _NET_ACTIVE_WINDOW for window {}", window);
1732
1733 if self.windows.contains_key(&window) {
1734 // Get the window's workspace and switch to it if needed
1735 if let Some(win) = self.windows.get(&window) {
1736 let ws_idx = win.workspace;
1737 if ws_idx != self.focused_workspace {
1738 self.switch_workspace(ws_idx)?;
1739 }
1740 }
1741 // Focus the window (external activation, no warp - user is already interacting)
1742 self.set_focus(window, false)?;
1743 if self.is_floating(window) {
1744 self.raise_window(window)?;
1745 }
1746 }
1747 } else if msg_type == self.conn.net_current_desktop {
1748 // Workspace switch request (from pagers, etc.)
1749 let desktop = event.data.as_data32()[0] as usize;
1750 tracing::debug!("ClientMessage: _NET_CURRENT_DESKTOP to {}", desktop);
1751
1752 if desktop < self.workspaces.len() {
1753 self.switch_workspace(desktop)?;
1754 }
1755 } else if msg_type == self.conn.net_close_window {
1756 // Close window request
1757 tracing::debug!("ClientMessage: _NET_CLOSE_WINDOW for window {}", window);
1758
1759 if self.windows.contains_key(&window) {
1760 self.close_window(window)?;
1761 }
1762 } else if msg_type == self.conn.net_wm_state {
1763 // Window state change request (fullscreen, etc.)
1764 let action = event.data.as_data32()[0];
1765 let property = event.data.as_data32()[1];
1766 tracing::debug!(
1767 "ClientMessage: _NET_WM_STATE action={} property={} for window {}",
1768 action, property, window
1769 );
1770
1771 // Handle fullscreen state changes
1772 if property == self.conn.net_wm_state_fullscreen {
1773 // action: 0 = remove, 1 = add, 2 = toggle
1774 match action {
1775 0 => {
1776 // Remove fullscreen
1777 self.set_fullscreen(window, false)?;
1778 }
1779 1 => {
1780 // Add fullscreen
1781 self.set_fullscreen(window, true)?;
1782 }
1783 2 => {
1784 // Toggle fullscreen
1785 self.toggle_fullscreen(window)?;
1786 }
1787 _ => {}
1788 }
1789 }
1790 // Handle ABOVE state changes - raise window when requested
1791 else if property == self.conn.net_wm_state_above {
1792 match action {
1793 1 | 2 => {
1794 // Add or toggle ABOVE - raise the window
1795 tracing::info!("Window {} requesting ABOVE state, raising", window);
1796 if self.windows.contains_key(&window) {
1797 self.raise_window(window)?;
1798 }
1799 }
1800 _ => {}
1801 }
1802 }
1803 } else {
1804 tracing::trace!("Unhandled ClientMessage type: {}", msg_type);
1805 }
1806
1807 self.conn.flush()?;
1808 Ok(())
1809 }
1810
1811 /// Handle PropertyNotify events (urgency hints, etc.).
1812 fn handle_property_notify(&mut self, event: PropertyNotifyEvent) -> Result<()> {
1813 let window = event.window;
1814 let atom = event.atom;
1815
1816 // Check if WM_HINTS changed (urgency flag may have changed)
1817 if atom == self.conn.wm_hints {
1818 // Only handle for managed windows
1819 if !self.windows.contains_key(&window) {
1820 return Ok(());
1821 }
1822
1823 tracing::debug!("WM_HINTS changed for window {}", window);
1824
1825 // Get the current WM_HINTS
1826 if let Some(hints) = self.conn.get_wm_hints(window) {
1827 let old_urgent = self.windows.get(&window).map(|w| w.urgent).unwrap_or(false);
1828 let new_urgent = hints.urgent;
1829
1830 if old_urgent != new_urgent {
1831 tracing::info!(
1832 "Window {} urgency changed: {} -> {}",
1833 window, old_urgent, new_urgent
1834 );
1835
1836 // Update window state
1837 if let Some(win) = self.windows.get_mut(&window) {
1838 win.urgent = new_urgent;
1839 }
1840
1841 // Update border colors
1842 self.update_borders()?;
1843 self.conn.flush()?;
1844 }
1845 }
1846 }
1847
1848 Ok(())
1849 }
1850
1851 /// Handle Expose events to redraw title bars.
1852 fn handle_expose(&mut self, event: ExposeEvent) -> Result<()> {
1853 let window = event.window;
1854
1855 // Only process when count is 0 (last expose in batch)
1856 if event.count != 0 {
1857 return Ok(());
1858 }
1859
1860 // Check if this is a frame window
1861 if let Some(client) = self.frames.client_for_frame(window) {
1862 // Redraw the title bar
1863 if self.config.titlebar_enabled {
1864 let win_state = self.windows.get(&client);
1865 let title = win_state.map(|w| w.title.as_str()).unwrap_or("");
1866 let focused = self.focused_window == Some(client);
1867
1868 let bg_color = if focused {
1869 self.config.titlebar_color_focused
1870 } else {
1871 self.config.titlebar_color_unfocused
1872 };
1873
1874 // Get frame width from expose event
1875 let width = event.width;
1876 let titlebar_height = self.config.titlebar_height as u16;
1877
1878 self.frames.draw_titlebar(
1879 &self.conn.conn,
1880 client,
1881 title,
1882 width,
1883 titlebar_height,
1884 bg_color,
1885 self.config.titlebar_text_color,
1886 focused,
1887 )?;
1888
1889 self.conn.flush()?;
1890 }
1891 }
1892
1893 Ok(())
1894 }
1895
1896 fn handle_key_press(&mut self, event: KeyPressEvent) -> Result<()> {
1897 let keycode = event.detail;
1898 let state = event.state;
1899
1900 // Convert KeyButMask to ModMask for comparison
1901 // Filter out NumLock (M2), CapsLock (Lock), and ScrollLock (M5)
1902 let modifiers = ModMask::from(
1903 (state.bits() & (ModMask::SHIFT | ModMask::CONTROL | ModMask::M1 | ModMask::M4).bits())
1904 as u16,
1905 );
1906
1907 tracing::trace!("KeyPress: keycode={}, raw_state={:?}, filtered_mods={:?}",
1908 keycode, state, modifiers);
1909
1910 // Find matching keybind from Lua config
1911 let action = {
1912 let lua_state = self.lua_state.lock().unwrap();
1913 lua_state
1914 .keybinds
1915 .iter()
1916 .find(|kb| {
1917 let bind_keycode = self.conn.keycode_from_keysym(kb.keysym);
1918 bind_keycode == Some(keycode) && kb.modifiers == modifiers
1919 })
1920 .map(|kb| kb.action.clone())
1921 };
1922
1923 if let Some(action) = action {
1924 tracing::debug!("Executing action: {:?}", action);
1925 self.execute_action(action)?;
1926 }
1927
1928 Ok(())
1929 }
1930
1931 fn execute_action(&mut self, action: Action) -> Result<()> {
1932 match action {
1933 Action::Exec(cmd) => {
1934 tracing::info!("Exec: {}", cmd);
1935 Command::new("sh").arg("-c").arg(&cmd).spawn().ok();
1936 }
1937 Action::CloseWindow => {
1938 if let Some(window) = self.focused_window {
1939 self.close_window(window)?;
1940 }
1941 }
1942 Action::ForceCloseWindow => {
1943 if let Some(window) = self.focused_window {
1944 self.force_close_window(window)?;
1945 }
1946 }
1947 Action::Focus(direction) => {
1948 if let Some(dir) = parse_direction(&direction) {
1949 self.focus_direction(dir)?;
1950 }
1951 }
1952 Action::Swap(direction) => {
1953 if let Some(dir) = parse_direction(&direction) {
1954 self.swap_direction(dir)?;
1955 }
1956 }
1957 Action::Resize(direction, amount) => {
1958 if let Some(dir) = parse_direction(&direction) {
1959 self.resize_direction(dir, amount)?;
1960 }
1961 }
1962 Action::Equalize => {
1963 self.equalize()?;
1964 }
1965 Action::Workspace(idx) => {
1966 // Lua uses 1-based indexing
1967 self.switch_workspace(idx.saturating_sub(1))?;
1968 }
1969 Action::WorkspaceNext => {
1970 // Find next workspace with windows, wrapping around
1971 let len = self.workspaces.len();
1972 for i in 1..=len {
1973 let idx = (self.focused_workspace + i) % len;
1974 if self.workspaces[idx].has_windows() {
1975 self.switch_workspace(idx)?;
1976 break;
1977 }
1978 }
1979 }
1980 Action::WorkspacePrev => {
1981 // Find previous workspace with windows, wrapping around
1982 let len = self.workspaces.len();
1983 for i in 1..=len {
1984 let idx = (self.focused_workspace + len - i) % len;
1985 if self.workspaces[idx].has_windows() {
1986 self.switch_workspace(idx)?;
1987 break;
1988 }
1989 }
1990 }
1991 Action::MoveToWorkspace(idx) => {
1992 // Lua uses 1-based indexing
1993 self.move_to_workspace(idx.saturating_sub(1))?;
1994 }
1995 Action::Reload => {
1996 self.reload_config()?;
1997 }
1998 Action::Exit => {
1999 tracing::info!("Exit requested, will exit event loop");
2000 self.running = false;
2001 // Force an immediate return from event handling
2002 return Ok(());
2003 }
2004 Action::ToggleFloating => {
2005 if let Some(window) = self.focused_window {
2006 self.toggle_floating(window)?;
2007 }
2008 }
2009 Action::ToggleFullscreen => {
2010 if let Some(window) = self.focused_window {
2011 self.toggle_fullscreen(window)?;
2012 }
2013 }
2014 Action::CycleFloating => {
2015 self.cycle_floating()?;
2016 }
2017 Action::FocusMonitor(target) => {
2018 self.focus_monitor(&target)?;
2019 }
2020 Action::MoveToMonitor(target) => {
2021 self.move_to_monitor(&target)?;
2022 }
2023 Action::LuaCallback(index) => {
2024 if let Err(e) = self.lua_config.execute_callback(index) {
2025 tracing::error!("Lua callback error: {}", e);
2026 }
2027 }
2028 }
2029 Ok(())
2030 }
2031
2032 fn close_window(&mut self, window: u32) -> Result<()> {
2033 // Verify we actually have a window to close
2034 if !self.windows.contains_key(&window) {
2035 tracing::warn!("close_window called on unmanaged window {}", window);
2036 return Ok(());
2037 }
2038
2039 tracing::info!("Closing window {}", window);
2040
2041 // Try graceful ICCCM close first
2042 if self.conn.supports_delete_window(window) {
2043 tracing::info!("Window {} supports WM_DELETE_WINDOW, sending graceful close", window);
2044 self.conn.send_delete_window(window)?;
2045 } else {
2046 tracing::info!("Window {} doesn't support WM_DELETE_WINDOW, using kill_client", window);
2047 self.conn.conn.kill_client(window)?;
2048 }
2049
2050 self.conn.flush()?;
2051 Ok(())
2052 }
2053
2054 fn force_close_window(&mut self, window: u32) -> Result<()> {
2055 tracing::info!("Force closing window {}", window);
2056 self.conn.conn.kill_client(window)?;
2057 self.conn.flush()?;
2058 Ok(())
2059 }
2060
2061 fn focus_direction(&mut self, direction: Direction) -> Result<()> {
2062 tracing::debug!(
2063 "focus_direction({:?}): focused_monitor={}, monitors={:?}",
2064 direction,
2065 self.focused_monitor,
2066 self.monitors.iter().map(|m| (&m.name, m.geometry.x)).collect::<Vec<_>>()
2067 );
2068
2069 let Some(focused) = self.focused_window else {
2070 // No focused window - try to focus adjacent monitor
2071 tracing::debug!("No focused window, trying adjacent monitor");
2072 return self.focus_adjacent_monitor(direction);
2073 };
2074
2075 let screen = self.screen_rect();
2076 let geometries = self.current_workspace().tree.calculate_geometries(screen);
2077 tracing::debug!("Window geometries on workspace: {:?}", geometries);
2078
2079 // Look up remembered window for this direction (window memory)
2080 let preferred = self.directional_focus_memory.get(&(focused, direction)).copied();
2081
2082 let adjacent = Node::find_adjacent(&geometries, focused, direction, preferred);
2083 tracing::debug!("find_adjacent result: {:?}", adjacent);
2084
2085 if let Some(target) = adjacent {
2086 // Check if memory was used (preferred matched target) or default algorithm was used
2087 let used_memory = preferred == Some(target);
2088
2089 tracing::info!("NAV: {:?} from {} to {}, preferred={:?}, used_memory={}",
2090 direction, focused, target, preferred, used_memory);
2091
2092 // Store the directional focus memory for next time
2093 self.directional_focus_memory.insert((focused, direction), target);
2094 tracing::info!("NAV: stored ({}, {:?}) -> {}", focused, direction, target);
2095
2096 // Always store reverse direction if windows are aligned (same row/column)
2097 // This ensures "go back" always returns to the window we came from
2098 if let (Some((_, from_rect)), Some((_, to_rect))) = (
2099 geometries.iter().find(|(w, _)| *w == focused),
2100 geometries.iter().find(|(w, _)| *w == target),
2101 ) {
2102 let overlaps = match direction {
2103 // For Left/Right: store reverse if windows share vertical space (same row)
2104 Direction::Left | Direction::Right => {
2105 let overlap_start = from_rect.y.max(to_rect.y);
2106 let overlap_end = (from_rect.y + from_rect.height as i16)
2107 .min(to_rect.y + to_rect.height as i16);
2108 tracing::info!("NAV: L/R overlap check: from_y={},{} to_y={},{} overlap=[{},{}]",
2109 from_rect.y, from_rect.height, to_rect.y, to_rect.height, overlap_start, overlap_end);
2110 overlap_start < overlap_end
2111 }
2112 // For Up/Down: store reverse if windows share horizontal space (same column)
2113 Direction::Up | Direction::Down => {
2114 let overlap_start = from_rect.x.max(to_rect.x);
2115 let overlap_end = (from_rect.x + from_rect.width as i16)
2116 .min(to_rect.x + to_rect.width as i16);
2117 tracing::info!("NAV: U/D overlap check: from_x={},{} to_x={},{} overlap=[{},{}]",
2118 from_rect.x, from_rect.width, to_rect.x, to_rect.width, overlap_start, overlap_end);
2119 overlap_start < overlap_end
2120 }
2121 };
2122
2123 tracing::info!("NAV: overlaps={}", overlaps);
2124 if overlaps {
2125 let opposite = match direction {
2126 Direction::Left => Direction::Right,
2127 Direction::Right => Direction::Left,
2128 Direction::Up => Direction::Down,
2129 Direction::Down => Direction::Up,
2130 };
2131 self.directional_focus_memory.insert((target, opposite), focused);
2132 tracing::info!("NAV: stored reverse ({}, {:?}) -> {}", target, opposite, focused);
2133 }
2134 }
2135
2136 // Focus new window (keyboard navigation, warp pointer)
2137 // set_focus handles grab/ungrab for old and new windows
2138 self.set_focus(target, true)?;
2139 self.conn.flush()?;
2140
2141 tracing::debug!("Focused {:?} to window {} (preferred: {:?})", direction, target, preferred);
2142 } else {
2143 // No adjacent window on this workspace - try adjacent monitor
2144 tracing::debug!("No adjacent window found, trying adjacent monitor");
2145 self.focus_adjacent_monitor(direction)?;
2146 }
2147
2148 Ok(())
2149 }
2150
2151 /// Focus the adjacent monitor in the given direction (does NOT wrap at edges)
2152 fn focus_adjacent_monitor(&mut self, direction: Direction) -> Result<()> {
2153 tracing::debug!(
2154 "focus_adjacent_monitor({:?}): focused_monitor={}, num_monitors={}",
2155 direction, self.focused_monitor, self.monitors.len()
2156 );
2157
2158 if self.monitors.len() <= 1 {
2159 tracing::debug!("Only one monitor, nothing to do");
2160 return Ok(());
2161 }
2162
2163 // Calculate target index WITHOUT wrapping
2164 let target_idx = match direction {
2165 Direction::Left => {
2166 if self.focused_monitor == 0 {
2167 // At leftmost monitor - do nothing
2168 tracing::debug!("Already at leftmost monitor (index 0), not navigating left");
2169 return Ok(());
2170 }
2171 self.focused_monitor - 1
2172 }
2173 Direction::Right => {
2174 if self.focused_monitor >= self.monitors.len() - 1 {
2175 // At rightmost monitor - do nothing
2176 tracing::debug!("Already at rightmost monitor, not navigating right");
2177 return Ok(());
2178 }
2179 self.focused_monitor + 1
2180 }
2181 // Up/Down could navigate if monitors are stacked vertically
2182 Direction::Up | Direction::Down => {
2183 tracing::debug!("Up/Down navigation not supported for horizontal monitor layout");
2184 return Ok(());
2185 }
2186 };
2187
2188 tracing::info!("Moving focus from monitor {} to {}", self.focused_monitor, target_idx);
2189 self.focused_monitor = target_idx;
2190
2191 // Focus the active workspace on that monitor
2192 let workspace_idx = self.monitors[target_idx].active_workspace;
2193 self.focused_workspace = workspace_idx;
2194
2195 // Focus a window on that workspace if any, or just warp to monitor center
2196 if let Some(window) = self.workspaces[workspace_idx].focused
2197 .or_else(|| self.workspaces[workspace_idx].floating.last().copied())
2198 .or_else(|| self.workspaces[workspace_idx].tree.first_window())
2199 {
2200 // set_focus handles grab/ungrab for old and new windows
2201 self.set_focus(window, true)?;
2202 } else {
2203 // No windows on target monitor - clear focus and warp to monitor center
2204 self.focused_window = None;
2205 self.warp_to_monitor(target_idx)?;
2206 tracing::debug!("No windows on monitor {}, warped to center", target_idx);
2207 }
2208
2209 self.conn.flush()?;
2210 Ok(())
2211 }
2212
2213 fn swap_direction(&mut self, direction: Direction) -> Result<()> {
2214 let Some(focused) = self.focused_window else {
2215 return Ok(());
2216 };
2217
2218 let screen = self.screen_rect();
2219 let geometries = self.current_workspace().tree.calculate_geometries(screen);
2220
2221 if let Some(target) = Node::find_adjacent(&geometries, focused, direction, None) {
2222 // Swap the windows in the tree
2223 self.current_workspace_mut().tree.swap(focused, target);
2224
2225 // Re-apply layout
2226 self.apply_layout()?;
2227
2228 // Keep focus on the original window (now in swapped position)
2229 self.set_focus(focused, true)?;
2230
2231 tracing::debug!("Swapped with window {} in direction {:?}", target, direction);
2232 } else if self.monitors.len() > 1 {
2233 // No adjacent window - try moving to adjacent monitor
2234 let target_monitor = match direction {
2235 Direction::Left => Some("prev"),
2236 Direction::Right => Some("next"),
2237 // For up/down with horizontal monitor arrangement, could also try prev/next
2238 // but typically vertical movement doesn't cross monitors
2239 Direction::Up | Direction::Down => None,
2240 };
2241
2242 if let Some(target) = target_monitor {
2243 tracing::debug!("No adjacent window, moving to {} monitor", target);
2244 self.move_to_monitor(target)?;
2245 }
2246 }
2247
2248 Ok(())
2249 }
2250
2251 fn resize_direction(&mut self, direction: Direction, delta: f32) -> Result<()> {
2252 let Some(focused) = self.focused_window else {
2253 return Ok(());
2254 };
2255
2256 // Resize the split
2257 if self
2258 .current_workspace_mut()
2259 .tree
2260 .resize(focused, direction, delta)
2261 {
2262 // Re-apply layout
2263 self.apply_layout()?;
2264 tracing::debug!("Resized {:?}", direction);
2265 }
2266
2267 Ok(())
2268 }
2269
2270 fn equalize(&mut self) -> Result<()> {
2271 self.current_workspace_mut().tree.equalize();
2272 self.apply_layout()?;
2273 tracing::debug!("Equalized splits");
2274 Ok(())
2275 }
2276
2277 /// Force refresh layout - just re-apply layout.
2278 /// Note: GTK apps may not fully re-render at new scale without restart.
2279 fn force_refresh_layout(&mut self) -> Result<()> {
2280 self.apply_layout()?;
2281 tracing::info!("Layout refreshed");
2282 Ok(())
2283 }
2284
2285 /// Execute an i3-compatible command (from IPC RUN_COMMAND).
2286 /// Returns true if the command was executed successfully.
2287 fn execute_i3_command(&mut self, cmd: &str) -> bool {
2288 let cmd = cmd.trim();
2289 tracing::debug!("Executing i3 command: {}", cmd);
2290
2291 // Parse "workspace <name|number>" command
2292 // Use switch_workspace_impl with warp_pointer=false since IPC commands
2293 // (like clicks from the bar) shouldn't move the mouse cursor
2294 if let Some(rest) = cmd.strip_prefix("workspace ") {
2295 let rest = rest.trim();
2296 // Try to parse as number first
2297 if let Ok(num) = rest.parse::<usize>() {
2298 // Workspace numbers are 1-indexed in i3
2299 let idx = num.saturating_sub(1);
2300 if idx < self.workspaces.len() {
2301 if let Err(e) = self.switch_workspace_impl(idx, false) {
2302 tracing::warn!("Failed to switch workspace: {}", e);
2303 return false;
2304 }
2305 return true;
2306 }
2307 }
2308 // Try to match by name
2309 if let Some(idx) = self.workspaces.iter().position(|ws| ws.name == rest) {
2310 if let Err(e) = self.switch_workspace_impl(idx, false) {
2311 tracing::warn!("Failed to switch workspace: {}", e);
2312 return false;
2313 }
2314 return true;
2315 }
2316 tracing::warn!("Workspace not found: {}", rest);
2317 return false;
2318 }
2319
2320 // Parse "workspace number <n>" command
2321 if let Some(rest) = cmd.strip_prefix("workspace number ") {
2322 if let Ok(num) = rest.trim().parse::<usize>() {
2323 let idx = num.saturating_sub(1);
2324 if idx < self.workspaces.len() {
2325 if let Err(e) = self.switch_workspace_impl(idx, false) {
2326 tracing::warn!("Failed to switch workspace: {}", e);
2327 return false;
2328 }
2329 return true;
2330 }
2331 }
2332 return false;
2333 }
2334
2335 tracing::debug!("Unknown i3 command: {}", cmd);
2336 false
2337 }
2338
2339 /// Switch to workspace using i3-style behavior:
2340 /// - If workspace is visible on another monitor, focus moves to that monitor
2341 /// - If workspace is not visible, it appears on the current monitor
2342 /// If warp_pointer is false, the mouse cursor is not moved (for IPC commands).
2343 fn switch_workspace_impl(&mut self, idx: usize, warp_pointer: bool) -> Result<()> {
2344 if idx >= self.workspaces.len() {
2345 return Ok(());
2346 }
2347
2348 // Track old workspace for event broadcasting
2349 let old_workspace_idx = self.focused_workspace;
2350
2351 // Check if workspace is already visible on some monitor
2352 let visible_on_monitor = self.monitors.iter().position(|m| m.active_workspace == idx);
2353
2354 if let Some(monitor_idx) = visible_on_monitor {
2355 // Workspace is already visible - just focus that monitor (i3 behavior)
2356 if monitor_idx == self.focused_monitor {
2357 // Already on this workspace on this monitor
2358 return Ok(());
2359 }
2360
2361 tracing::info!(
2362 "Workspace {} already visible on monitor {}, focusing it",
2363 idx + 1, monitor_idx
2364 );
2365
2366 // Focus the monitor that has this workspace
2367 self.focused_monitor = monitor_idx;
2368 self.focused_workspace = idx;
2369
2370 // Update EWMH
2371 self.conn.set_current_desktop(idx as u32)?;
2372
2373 // Warp pointer to that monitor (only if requested)
2374 if warp_pointer {
2375 let monitor_geom = self.monitors[monitor_idx].geometry;
2376 let center_x = monitor_geom.x + (monitor_geom.width as i16 / 2);
2377 let center_y = monitor_geom.y + (monitor_geom.height as i16 / 2);
2378 self.conn.warp_pointer(center_x, center_y)?;
2379 self.last_warp = std::time::Instant::now();
2380 }
2381
2382 // Focus a window on that workspace
2383 if let Some(window) = self.workspaces[idx].focused
2384 .or_else(|| self.workspaces[idx].floating.last().copied())
2385 .or_else(|| self.workspaces[idx].tree.first_window())
2386 {
2387 self.set_focus(window, warp_pointer)?;
2388 } else {
2389 self.focused_window = None;
2390 self.conn.set_active_window(None)?;
2391 }
2392 } else {
2393 // Workspace not visible - show it on current monitor (i3 behavior)
2394 let current_monitor = self.focused_monitor;
2395 let old_ws = self.monitors[current_monitor].active_workspace;
2396
2397 tracing::info!(
2398 "Switching monitor {} from workspace {} to {}",
2399 current_monitor, old_ws + 1, idx + 1
2400 );
2401
2402 // Hide windows on old workspace
2403 for window in self.workspaces[old_ws].all_windows() {
2404 // Mark as intentional unmap so UnmapNotify handler ignores it
2405 if let Some(win) = self.windows.get_mut(&window) {
2406 win.ignore_unmap_count += 1;
2407 }
2408 self.conn.unmap_window(window)?;
2409 // Also unmap frames if present
2410 if let Some(frame) = self.frames.frame_for_client(window) {
2411 self.conn.unmap_window(frame)?;
2412 }
2413 }
2414
2415 // Update monitor's active workspace
2416 self.monitors[current_monitor].active_workspace = idx;
2417 self.focused_workspace = idx;
2418
2419 // Update EWMH
2420 self.conn.set_current_desktop(idx as u32)?;
2421
2422 // Show windows on new workspace
2423 for window in self.workspaces[idx].all_windows() {
2424 if let Some(frame) = self.frames.frame_for_client(window) {
2425 self.conn.map_window(frame)?;
2426 }
2427 self.conn.map_window(window)?;
2428 }
2429
2430 // Apply layout
2431 self.apply_layout()?;
2432
2433 // Focus a window on the new workspace
2434 if let Some(window) = self.workspaces[idx].focused
2435 .or_else(|| self.workspaces[idx].floating.last().copied())
2436 .or_else(|| self.workspaces[idx].tree.first_window())
2437 {
2438 self.set_focus(window, warp_pointer)?;
2439 } else {
2440 // No windows - warp to center of monitor (only if requested)
2441 self.focused_window = None;
2442 self.conn.set_active_window(None)?;
2443 if warp_pointer {
2444 let monitor_geom = self.monitors[current_monitor].geometry;
2445 let center_x = monitor_geom.x + (monitor_geom.width as i16 / 2);
2446 let center_y = monitor_geom.y + (monitor_geom.height as i16 / 2);
2447 self.conn.warp_pointer(center_x, center_y)?;
2448 self.last_warp = std::time::Instant::now();
2449 }
2450 }
2451 }
2452
2453 // Broadcast i3 workspace event for polybar
2454 if idx != old_workspace_idx {
2455 self.broadcast_i3_workspace_event("focus", idx, Some(old_workspace_idx));
2456 }
2457
2458 self.conn.flush()?;
2459 Ok(())
2460 }
2461
2462 /// Switch to workspace with pointer warping (default behavior for keybinds).
2463 fn switch_workspace(&mut self, idx: usize) -> Result<()> {
2464 self.switch_workspace_impl(idx, true)
2465 }
2466
2467 fn move_to_workspace(&mut self, idx: usize) -> Result<()> {
2468 if idx >= self.workspaces.len() {
2469 return Ok(());
2470 }
2471
2472 let Some(window) = self.focused_window else {
2473 return Ok(());
2474 };
2475
2476 // Get current workspace for this window
2477 let current_ws = self.windows.get(&window).map(|w| w.workspace).unwrap_or(self.focused_workspace);
2478
2479 // Don't move if already on target workspace
2480 if idx == current_ws {
2481 return Ok(());
2482 }
2483
2484 let is_floating = self.windows.get(&window).map(|w| w.floating).unwrap_or(false);
2485
2486 tracing::info!("Moving window {} from workspace {} to {} (floating: {})",
2487 window, current_ws + 1, idx + 1, is_floating);
2488
2489 // Remove from current workspace (tree or floating list)
2490 if is_floating {
2491 self.workspaces[current_ws].remove_floating(window);
2492 } else {
2493 self.workspaces[current_ws].tree.remove(window);
2494 }
2495
2496 // Update focus on current workspace
2497 let new_focus_on_current = self.workspaces[current_ws].tree.first_window()
2498 .or_else(|| self.workspaces[current_ws].floating.last().copied());
2499 self.workspaces[current_ws].focused = new_focus_on_current;
2500
2501 // Update window's workspace tracking
2502 if let Some(win) = self.windows.get_mut(&window) {
2503 win.workspace = idx;
2504 }
2505
2506 // Update EWMH _NET_WM_DESKTOP
2507 self.conn.set_window_desktop(window, idx as u32)?;
2508
2509 // Check if target workspace is visible on any monitor
2510 let target_visible_on = self.monitors.iter().position(|m| m.active_workspace == idx);
2511
2512 // Insert into target workspace
2513 if is_floating {
2514 self.workspaces[idx].add_floating(window);
2515 } else {
2516 let target_focused = self.workspaces[idx].focused;
2517 // Use target monitor's geometry if visible, otherwise use current monitor's
2518 let screen = if let Some(mon_idx) = target_visible_on {
2519 self.monitors[mon_idx].geometry
2520 } else {
2521 self.monitors[self.focused_monitor].geometry
2522 };
2523 self.workspaces[idx]
2524 .tree
2525 .insert_with_rect(window, target_focused, screen);
2526 }
2527
2528 // Set target workspace focus to the moved window so switch_workspace will focus it
2529 self.workspaces[idx].focused = Some(window);
2530
2531 // If target workspace is visible, map the window; otherwise hide it
2532 if target_visible_on.is_some() {
2533 // Target is visible - map the window
2534 if let Some(frame) = self.frames.frame_for_client(window) {
2535 self.conn.map_window(frame)?;
2536 }
2537 self.conn.map_window(window)?;
2538 } else {
2539 // Target is not visible - hide the window
2540 // Mark as intentional unmap so UnmapNotify handler ignores it
2541 if let Some(win) = self.windows.get_mut(&window) {
2542 win.ignore_unmap_count += 1;
2543 }
2544 self.conn.unmap_window(window)?;
2545 if let Some(frame) = self.frames.frame_for_client(window) {
2546 self.conn.unmap_window(frame)?;
2547 }
2548 }
2549
2550 // Re-apply layout
2551 self.apply_layout()?;
2552
2553 // Check if we should follow the window to the target workspace
2554 if self.config.follow_window_on_move {
2555 // Switch to target workspace (this will focus the moved window)
2556 self.switch_workspace(idx)?;
2557 } else {
2558 // Stay on current workspace, update focus to next window
2559 if current_ws == self.focused_workspace {
2560 self.focused_window = new_focus_on_current;
2561 if let Some(new_focus) = self.focused_window {
2562 self.set_focus(new_focus, true)?;
2563 } else {
2564 self.conn.set_active_window(None)?;
2565 }
2566 }
2567 }
2568
2569 self.conn.flush()?;
2570 Ok(())
2571 }
2572
2573 fn reload_config(&mut self) -> Result<()> {
2574 tracing::info!("Reloading configuration");
2575
2576 // Ungrab all current keys
2577 // (We'd need to track grabbed keys to ungrab them properly,
2578 // for now we'll just regrab - X11 handles duplicates)
2579
2580 // Reload Lua config
2581 if let Err(e) = self.lua_config.reload() {
2582 tracing::error!("Config reload failed: {}", e);
2583 return Ok(());
2584 }
2585
2586 // Update config from Lua state
2587 {
2588 let state = self.lua_state.lock().unwrap();
2589 self.config = state.config.clone();
2590 }
2591
2592 // Regenerate picom config and signal picom to reload
2593 if let Err(e) = self.config.write_picom_config() {
2594 tracing::warn!("Failed to regenerate picom config: {}", e);
2595 }
2596
2597 // Apply screen timeout/DPMS settings
2598 self.config.apply_screen_timeout();
2599
2600 // Re-register keybinds
2601 self.setup_grabs()?;
2602
2603 // Re-apply layout with new settings
2604 self.apply_layout()?;
2605
2606 // Handle garbar lifecycle based on new config
2607 if self.config.bar_enabled {
2608 // Check if garbar is still running AND healthy (socket exists)
2609 let (garbar_alive, garbar_healthy) = if let Some(ref mut child) = self.garbar_process {
2610 match child.try_wait() {
2611 Ok(None) => (true, is_garbar_healthy()), // Process running, check socket
2612 Ok(Some(status)) => {
2613 tracing::info!("garbar exited with status {}, will respawn", status);
2614 (false, false)
2615 }
2616 Err(e) => {
2617 tracing::warn!("Failed to check garbar status: {}", e);
2618 (false, false)
2619 }
2620 }
2621 } else {
2622 (false, false)
2623 };
2624
2625 if garbar_alive && garbar_healthy {
2626 // garbar running and healthy, signal it to reload
2627 if let Some(ref child) = self.garbar_process {
2628 reload_garbar(child);
2629 }
2630 } else if garbar_alive && !garbar_healthy {
2631 // garbar process exists but socket doesn't - it's stuck
2632 tracing::warn!("garbar process alive but socket missing, restarting...");
2633 if let Some(ref mut child) = self.garbar_process {
2634 let _ = child.kill();
2635 let _ = child.wait();
2636 }
2637 self.garbar_process = spawn_garbar();
2638 } else {
2639 // garbar not running, spawn it
2640 self.garbar_process = spawn_garbar();
2641 }
2642 } else if let Some(ref mut child) = self.garbar_process {
2643 // bar_enabled is now false, stop garbar
2644 stop_garbar(child);
2645 self.garbar_process = None;
2646 }
2647
2648 tracing::info!("Configuration reloaded");
2649 Ok(())
2650 }
2651
2652 pub fn run(&mut self) -> Result<()> {
2653 tracing::info!("Starting event loop");
2654
2655 // Set up keybinds
2656 self.setup_grabs()?;
2657
2658 // Set up EWMH workspace hints
2659 self.setup_ewmh_hints()?;
2660
2661 // Adopt any existing windows
2662 self.adopt_existing_windows()?;
2663
2664 // Signal systemd that graphical session has started
2665 // This allows user services (like garbg) bound to graphical-session.target to start
2666 start_graphical_session();
2667
2668 // Spawn garbar if gar.bar is configured
2669 if self.config.bar_enabled {
2670 self.garbar_process = spawn_garbar();
2671 }
2672
2673 // Spawn garnotify if gar.notification is configured
2674 if self.config.notification_enabled {
2675 self.garnotify_process = spawn_garnotify();
2676 }
2677
2678 while self.running {
2679 // Handle X11 events (non-blocking poll)
2680 while let Some(event) = self.conn.conn.poll_for_event()? {
2681 self.handle_event(event)?;
2682 }
2683
2684 // Handle IPC requests
2685 self.handle_ipc()?;
2686
2687 // Handle i3-compatible IPC requests (for polybar)
2688 self.handle_i3_ipc()?;
2689
2690 // Reap any zombie child processes (from exec/exec_once)
2691 reap_zombies();
2692
2693 // Small sleep to avoid busy-waiting when idle
2694 std::thread::sleep(std::time::Duration::from_millis(10));
2695 }
2696
2697 // Unmap all managed windows so they don't persist on the X server
2698 // This ensures windows aren't visible when returning to the greeter
2699 tracing::info!("Unmapping {} managed windows", self.windows.len());
2700 for &window in self.windows.keys() {
2701 tracing::debug!("Unmapping window {}", window);
2702 let _ = self.conn.conn.unmap_window(window);
2703 }
2704 // Sync to ensure X server processes all unmap requests before we exit
2705 let _ = self.conn.sync();
2706 tracing::info!("Windows unmapped and synced");
2707
2708 // Kill all processes spawned via gar.exec()/gar.exec_once()
2709 if let Ok(state) = self.lua_state.lock() {
2710 state.kill_spawned_children();
2711 }
2712
2713 // Stop garbar if it was spawned
2714 if let Some(ref mut child) = self.garbar_process {
2715 stop_garbar(child);
2716 }
2717 self.garbar_process = None;
2718
2719 // Stop garnotify if it was spawned
2720 if let Some(ref mut child) = self.garnotify_process {
2721 stop_garnotify(child);
2722 }
2723 self.garnotify_process = None;
2724
2725 // Kill compositor to prevent overlay from bleeding into the greeter
2726 tracing::info!("Killing compositor...");
2727 // Use -f to match against full command line (needed for NixOS wrappers)
2728 let _ = std::process::Command::new("pkill")
2729 .args(["-f", "garchomp"])
2730 .status();
2731 let _ = std::process::Command::new("pkill")
2732 .args(["-f", "picom"])
2733 .status();
2734
2735 // Signal systemd that graphical session has ended
2736 // This stops user services bound to graphical-session.target (like garbg)
2737 stop_graphical_session();
2738
2739 tracing::info!("Event loop exited");
2740 Ok(())
2741 }
2742
2743 /// Handle pending IPC requests
2744 fn handle_ipc(&mut self) -> Result<()> {
2745 let Some(ref mut ipc) = self.ipc_server else {
2746 return Ok(());
2747 };
2748
2749 // Accept new connections
2750 ipc.accept_connections();
2751
2752 // Process requests
2753 let requests = ipc.poll_requests();
2754 for (client_idx, request) in requests {
2755 let response = self.dispatch_ipc_command(&request.command, request.args);
2756 if let Some(ref mut ipc) = self.ipc_server {
2757 ipc.send_response(client_idx, response);
2758 }
2759 }
2760
2761 Ok(())
2762 }
2763
2764 /// Dispatch an IPC command and return a response
2765 fn dispatch_ipc_command(&mut self, command: &str, args: serde_json::Value) -> crate::ipc::Response {
2766 use crate::ipc::Response;
2767
2768 match command {
2769 "focus" => {
2770 let direction = args.get("direction").and_then(|v| v.as_str()).unwrap_or("");
2771 if let Some(dir) = parse_direction(direction) {
2772 match self.focus_direction(dir) {
2773 Ok(_) => Response::success(None),
2774 Err(e) => Response::error(e.to_string()),
2775 }
2776 } else {
2777 Response::error(format!("Invalid direction: {}", direction))
2778 }
2779 }
2780 "swap" => {
2781 let direction = args.get("direction").and_then(|v| v.as_str()).unwrap_or("");
2782 if let Some(dir) = parse_direction(direction) {
2783 match self.swap_direction(dir) {
2784 Ok(_) => Response::success(None),
2785 Err(e) => Response::error(e.to_string()),
2786 }
2787 } else {
2788 Response::error(format!("Invalid direction: {}", direction))
2789 }
2790 }
2791 "resize" => {
2792 let direction = args.get("direction").and_then(|v| v.as_str()).unwrap_or("");
2793 let amount = args.get("amount").and_then(|v| v.as_f64()).unwrap_or(0.05) as f32;
2794 if let Some(dir) = parse_direction(direction) {
2795 match self.resize_direction(dir, amount) {
2796 Ok(_) => Response::success(None),
2797 Err(e) => Response::error(e.to_string()),
2798 }
2799 } else {
2800 Response::error(format!("Invalid direction: {}", direction))
2801 }
2802 }
2803 "close" => {
2804 if let Some(window) = self.focused_window {
2805 match self.close_window(window) {
2806 Ok(_) => Response::success(None),
2807 Err(e) => Response::error(e.to_string()),
2808 }
2809 } else {
2810 Response::error("No focused window")
2811 }
2812 }
2813 "workspace" => {
2814 let n = args.get("number").and_then(|v| v.as_u64()).unwrap_or(1) as usize;
2815 match self.switch_workspace(n.saturating_sub(1)) {
2816 Ok(_) => Response::success(None),
2817 Err(e) => Response::error(e.to_string()),
2818 }
2819 }
2820 "move_to_workspace" => {
2821 let n = args.get("number").and_then(|v| v.as_u64()).unwrap_or(1) as usize;
2822 match self.move_to_workspace(n.saturating_sub(1)) {
2823 Ok(_) => Response::success(None),
2824 Err(e) => Response::error(e.to_string()),
2825 }
2826 }
2827 "toggle_floating" => {
2828 if let Some(window) = self.focused_window {
2829 match self.toggle_floating(window) {
2830 Ok(_) => Response::success(None),
2831 Err(e) => Response::error(e.to_string()),
2832 }
2833 } else {
2834 Response::error("No focused window")
2835 }
2836 }
2837 "equalize" => {
2838 match self.equalize() {
2839 Ok(_) => Response::success(None),
2840 Err(e) => Response::error(e.to_string()),
2841 }
2842 }
2843 "refresh_layout" => {
2844 // Re-apply layout to all windows without changing ratios.
2845 // Useful after display scaling changes when GTK apps resize internally.
2846 // Two-step approach: first shrink windows, then expand - forces GTK to re-layout.
2847 match self.force_refresh_layout() {
2848 Ok(_) => {
2849 tracing::info!("Layout force-refreshed");
2850 Response::success(None)
2851 }
2852 Err(e) => Response::error(e.to_string()),
2853 }
2854 }
2855 "reload" => {
2856 match self.reload_config() {
2857 Ok(_) => Response::success(None),
2858 Err(e) => Response::error(e.to_string()),
2859 }
2860 }
2861 "exit" => {
2862 self.running = false;
2863 Response::success(None)
2864 }
2865 "get_workspaces" => {
2866 Response::success(Some(self.get_workspaces_json()))
2867 }
2868 "get_focused" => {
2869 Response::success(Some(self.get_focused_json()))
2870 }
2871 "get_tree" => {
2872 Response::success(Some(self.get_tree_json()))
2873 }
2874 "subscribe" => {
2875 // Handle subscription in handle_ipc directly
2876 Response::success(None)
2877 }
2878 "focus_monitor" => {
2879 let target = args.get("target").and_then(|v| v.as_str()).unwrap_or("next");
2880 match self.focus_monitor(target) {
2881 Ok(_) => Response::success(None),
2882 Err(e) => Response::error(e.to_string()),
2883 }
2884 }
2885 "move_to_monitor" => {
2886 let target = args.get("target").and_then(|v| v.as_str()).unwrap_or("next");
2887 match self.move_to_monitor(target) {
2888 Ok(_) => Response::success(None),
2889 Err(e) => Response::error(e.to_string()),
2890 }
2891 }
2892 "get_monitors" => {
2893 Response::success(Some(self.get_monitors_json()))
2894 }
2895 _ => Response::error(format!("Unknown command: {}", command)),
2896 }
2897 }
2898
2899 /// Get workspace info as JSON
2900 fn get_workspaces_json(&self) -> serde_json::Value {
2901 serde_json::json!(self.workspaces.iter().enumerate().map(|(i, ws)| {
2902 serde_json::json!({
2903 "id": ws.id,
2904 "name": ws.name,
2905 "focused": i == self.focused_workspace,
2906 "tiled_count": ws.tree.window_count(),
2907 "floating_count": ws.floating.len(),
2908 })
2909 }).collect::<Vec<_>>())
2910 }
2911
2912 /// Get focused window info as JSON
2913 fn get_focused_json(&self) -> serde_json::Value {
2914 match self.focused_window {
2915 Some(win_id) => {
2916 if let Some(win) = self.windows.get(&win_id) {
2917 serde_json::json!({
2918 "id": win_id,
2919 "workspace": win.workspace + 1,
2920 "floating": win.floating,
2921 })
2922 } else {
2923 serde_json::json!(null)
2924 }
2925 }
2926 None => serde_json::json!(null),
2927 }
2928 }
2929
2930 /// Get window tree as JSON
2931 fn get_tree_json(&self) -> serde_json::Value {
2932 serde_json::json!({
2933 "focused_workspace": self.focused_workspace + 1,
2934 "workspaces": self.workspaces.iter().map(|ws| {
2935 serde_json::json!({
2936 "id": ws.id,
2937 "name": ws.name,
2938 "tiled": ws.tree.windows(),
2939 "floating": ws.floating,
2940 })
2941 }).collect::<Vec<_>>()
2942 })
2943 }
2944
2945 /// Get monitor info as JSON
2946 fn get_monitors_json(&self) -> serde_json::Value {
2947 serde_json::json!(self.monitors.iter().enumerate().map(|(i, mon)| {
2948 serde_json::json!({
2949 "name": mon.name,
2950 "focused": i == self.focused_monitor,
2951 "primary": mon.primary,
2952 "geometry": {
2953 "x": mon.geometry.x,
2954 "y": mon.geometry.y,
2955 "width": mon.geometry.width,
2956 "height": mon.geometry.height,
2957 },
2958 "workspaces": mon.workspaces.iter().map(|ws| ws + 1).collect::<Vec<_>>(),
2959 "active_workspace": mon.active_workspace + 1,
2960 })
2961 }).collect::<Vec<_>>())
2962 }
2963
2964 // Floating window helpers
2965
2966 fn is_floating(&self, window: u32) -> bool {
2967 self.windows
2968 .get(&window)
2969 .map(|w| w.floating)
2970 .unwrap_or(false)
2971 }
2972
2973 /// Get the appropriate cursor for a resize edge.
2974 fn cursor_for_edge(&self, edge: ResizeEdge) -> u32 {
2975 match edge {
2976 ResizeEdge::TopLeft => self.conn.cursor_top_left,
2977 ResizeEdge::Top => self.conn.cursor_top,
2978 ResizeEdge::TopRight => self.conn.cursor_top_right,
2979 ResizeEdge::Left => self.conn.cursor_left,
2980 ResizeEdge::Right => self.conn.cursor_right,
2981 ResizeEdge::BottomLeft => self.conn.cursor_bottom_left,
2982 ResizeEdge::Bottom => self.conn.cursor_bottom,
2983 ResizeEdge::BottomRight => self.conn.cursor_bottom_right,
2984 ResizeEdge::None => self.conn.cursor_normal,
2985 }
2986 }
2987
2988
2989 fn get_floating_geometry(&self, window: u32) -> Rect {
2990 self.windows
2991 .get(&window)
2992 .map(|w| w.floating_geometry)
2993 .unwrap_or_default()
2994 }
2995
2996 fn set_floating_position(&mut self, window: u32, x: i16, y: i16) -> Result<()> {
2997 if let Some(win) = self.windows.get_mut(&window) {
2998 win.floating_geometry.x = x;
2999 win.floating_geometry.y = y;
3000 self.conn.configure_window(
3001 window,
3002 x,
3003 y,
3004 win.floating_geometry.width,
3005 win.floating_geometry.height,
3006 self.config.border_width,
3007 )?;
3008 self.conn.flush()?;
3009 }
3010 Ok(())
3011 }
3012
3013 fn set_floating_geometry(&mut self, window: u32, x: i16, y: i16, w: u16, h: u16) -> Result<()> {
3014 if let Some(win) = self.windows.get_mut(&window) {
3015 win.floating_geometry = Rect::new(x, y, w, h);
3016 self.conn.configure_window(
3017 window,
3018 x,
3019 y,
3020 w,
3021 h,
3022 self.config.border_width,
3023 )?;
3024 self.conn.flush()?;
3025 }
3026 Ok(())
3027 }
3028
3029 fn raise_window(&mut self, window: u32) -> Result<()> {
3030 // Update stacking order in workspace's floating list
3031 self.current_workspace_mut().raise_floating(window);
3032
3033 // Raise in X11 - if window has a frame, raise the frame instead
3034 let aux = ConfigureWindowAux::new().stack_mode(StackMode::ABOVE);
3035 if let Some(frame) = self.frames.frame_for_client(window) {
3036 tracing::info!("raise_window: window={} -> raising frame={}", window, frame);
3037 self.conn.conn.configure_window(frame, &aux)?;
3038 } else {
3039 tracing::info!("raise_window: window={} (no frame)", window);
3040 self.conn.conn.configure_window(window, &aux)?;
3041 }
3042 self.conn.flush()?;
3043 Ok(())
3044 }
3045
3046 /// Focus a different monitor.
3047 /// Target can be "next", "prev", "left", "right", or a monitor name.
3048 fn focus_monitor(&mut self, target: &str) -> Result<()> {
3049 if self.monitors.len() <= 1 {
3050 return Ok(());
3051 }
3052
3053 let target_idx = match target.to_lowercase().as_str() {
3054 "next" | "right" => (self.focused_monitor + 1) % self.monitors.len(),
3055 "prev" | "left" => {
3056 if self.focused_monitor == 0 {
3057 self.monitors.len() - 1
3058 } else {
3059 self.focused_monitor - 1
3060 }
3061 }
3062 name => {
3063 // Find monitor by name
3064 match self.monitors.iter().position(|m| m.name.eq_ignore_ascii_case(name)) {
3065 Some(idx) => idx,
3066 None => {
3067 tracing::warn!("Monitor '{}' not found", name);
3068 return Ok(());
3069 }
3070 }
3071 }
3072 };
3073
3074 if target_idx == self.focused_monitor {
3075 return Ok(());
3076 }
3077
3078 tracing::info!("Focusing monitor {}: '{}'", target_idx, self.monitors[target_idx].name);
3079 self.focused_monitor = target_idx;
3080
3081 // Focus the active workspace on that monitor
3082 let workspace_idx = self.monitors[target_idx].active_workspace;
3083 self.focused_workspace = workspace_idx;
3084
3085 // Focus a window on that workspace if any, or warp to monitor center
3086 if let Some(window) = self.workspaces[workspace_idx].focused
3087 .or_else(|| self.workspaces[workspace_idx].floating.last().copied())
3088 .or_else(|| self.workspaces[workspace_idx].tree.first_window())
3089 {
3090 // set_focus handles grab/ungrab for old and new windows
3091 self.set_focus(window, true)?;
3092 } else {
3093 // No windows - warp to monitor center
3094 self.focused_window = None;
3095 self.warp_to_monitor(target_idx)?;
3096 }
3097
3098 self.conn.flush()?;
3099 Ok(())
3100 }
3101
3102 /// Move focused window to another monitor.
3103 /// Target can be "next", "prev", "left", "right", or a monitor name.
3104 fn move_to_monitor(&mut self, target: &str) -> Result<()> {
3105 if self.monitors.len() <= 1 {
3106 return Ok(());
3107 }
3108
3109 let Some(window) = self.focused_window else {
3110 return Ok(());
3111 };
3112
3113 let target_idx = match target.to_lowercase().as_str() {
3114 "next" | "right" => (self.focused_monitor + 1) % self.monitors.len(),
3115 "prev" | "left" => {
3116 if self.focused_monitor == 0 {
3117 self.monitors.len() - 1
3118 } else {
3119 self.focused_monitor - 1
3120 }
3121 }
3122 name => {
3123 match self.monitors.iter().position(|m| m.name.eq_ignore_ascii_case(name)) {
3124 Some(idx) => idx,
3125 None => {
3126 tracing::warn!("Monitor '{}' not found", name);
3127 return Ok(());
3128 }
3129 }
3130 }
3131 };
3132
3133 if target_idx == self.focused_monitor {
3134 return Ok(());
3135 }
3136
3137 let is_floating = self.windows.get(&window).map(|w| w.floating).unwrap_or(false);
3138 let target_workspace = self.monitors[target_idx].active_workspace;
3139
3140 tracing::info!("Moving window {} to monitor {}: '{}' (workspace {})",
3141 window, target_idx, self.monitors[target_idx].name, target_workspace + 1);
3142
3143 // Remove from current workspace
3144 if is_floating {
3145 self.current_workspace_mut().remove_floating(window);
3146 } else {
3147 self.current_workspace_mut().tree.remove(window);
3148 }
3149
3150 // Update window's workspace
3151 if let Some(win) = self.windows.get_mut(&window) {
3152 win.workspace = target_workspace;
3153 }
3154
3155 // Add to target workspace
3156 if is_floating {
3157 self.workspaces[target_workspace].add_floating(window);
3158 } else {
3159 let target_focused = self.workspaces[target_workspace].focused;
3160 let target_rect = self.monitors[target_idx].geometry;
3161 self.workspaces[target_workspace].tree.insert_with_rect(window, target_focused, target_rect);
3162 }
3163
3164 // Update EWMH
3165 self.conn.set_window_desktop(window, target_workspace as u32)?;
3166
3167 // Focus follows window to new monitor
3168 self.focused_monitor = target_idx;
3169 self.focused_workspace = target_workspace;
3170 self.workspaces[target_workspace].focused = Some(window);
3171
3172 // Apply layouts on both monitors
3173 self.apply_layout()?;
3174
3175 // Set X11 focus on the moved window (updates focused_window, button grabs, EWMH)
3176 self.set_focus(window, true)?;
3177
3178 self.conn.flush()?;
3179 Ok(())
3180 }
3181
3182 /// Cycle through floating windows on the current workspace.
3183 fn cycle_floating(&mut self) -> Result<()> {
3184 let floating = &self.current_workspace().floating;
3185 if floating.is_empty() {
3186 tracing::debug!("No floating windows to cycle");
3187 return Ok(());
3188 }
3189
3190 // Find current position in floating list
3191 let current_idx = self.focused_window
3192 .and_then(|w| floating.iter().position(|&fw| fw == w));
3193
3194 // Get next floating window (wrap around)
3195 let next_idx = match current_idx {
3196 Some(idx) => (idx + 1) % floating.len(),
3197 None => 0, // Not focused on a floating window, focus the first one
3198 };
3199
3200 let next_window = floating[next_idx];
3201
3202 // Focus and raise the next floating window (keyboard action, warp pointer)
3203 // set_focus handles grab/ungrab for old and new windows
3204 self.set_focus(next_window, true)?;
3205 self.raise_window(next_window)?;
3206
3207 tracing::debug!("Cycled to floating window {} (idx {})", next_window, next_idx);
3208 Ok(())
3209 }
3210
3211 fn toggle_floating(&mut self, window: u32) -> Result<()> {
3212 // Check if window is managed
3213 let Some(win_state) = self.windows.get(&window) else {
3214 tracing::warn!("toggle_floating: window {} not managed", window);
3215 return Ok(());
3216 };
3217 let is_floating = win_state.floating;
3218
3219 tracing::debug!(
3220 "toggle_floating: window={}, is_floating={}, in_tree={}, in_floating_list={}",
3221 window,
3222 is_floating,
3223 self.current_workspace().tree.contains(window),
3224 self.current_workspace().floating.contains(&window)
3225 );
3226
3227 if is_floating {
3228 // Return to tiled
3229 tracing::info!("Returning window {} to tiled", window);
3230
3231 // Update window state
3232 if let Some(win) = self.windows.get_mut(&window) {
3233 win.floating = false;
3234 }
3235
3236 // Event mask for tiled window (includes POINTER_MOTION for tiled edge resize)
3237 self.conn.select_input(
3238 window,
3239 EventMask::ENTER_WINDOW
3240 | EventMask::FOCUS_CHANGE
3241 | EventMask::PROPERTY_CHANGE
3242 | EventMask::STRUCTURE_NOTIFY
3243 | EventMask::POINTER_MOTION,
3244 )?;
3245
3246 // Clear edge cursor state if this window had one
3247 if self.current_edge_cursor.map(|(w, _)| w) == Some(window) {
3248 self.conn.clear_window_cursor(window)?;
3249 self.current_edge_cursor = None;
3250 }
3251
3252 // Remove from floating list
3253 self.current_workspace_mut().remove_floating(window);
3254
3255 // Find a target window to insert next to (not ourselves)
3256 let target = self.current_workspace().tree.first_window();
3257 let screen = self.screen_rect();
3258 self.current_workspace_mut()
3259 .tree
3260 .insert_with_rect(window, target, screen);
3261
3262 // Re-apply layout
3263 self.apply_layout()?;
3264 } else {
3265 // Make floating
3266 tracing::info!("Floating window {}", window);
3267
3268 // Check if window is actually in the tree
3269 if !self.current_workspace().tree.contains(window) {
3270 tracing::warn!("toggle_floating: window {} not in tree, cannot float", window);
3271 return Ok(());
3272 }
3273
3274 // Use a centered floating geometry (80% of screen size, centered)
3275 let screen = self.screen_rect();
3276 let float_w = (screen.width * 4 / 5).max(400);
3277 let float_h = (screen.height * 4 / 5).max(300);
3278 let float_x = screen.x + (screen.width as i16 - float_w as i16) / 2;
3279 let float_y = screen.y + (screen.height as i16 - float_h as i16) / 2;
3280 let geometry = Rect::new(float_x, float_y, float_w, float_h);
3281
3282 tracing::debug!("Floating geometry: {:?}", geometry);
3283
3284 // Remove from BSP tree
3285 let removed = self.current_workspace_mut().tree.remove(window);
3286 tracing::debug!("Removed from tree: {}", removed);
3287
3288 // Update window state with floating geometry
3289 if let Some(win) = self.windows.get_mut(&window) {
3290 win.floating = true;
3291 win.floating_geometry = geometry;
3292 }
3293
3294 // Event mask for floating window (includes POINTER_MOTION for edge cursor)
3295 self.conn.select_input(
3296 window,
3297 EventMask::ENTER_WINDOW
3298 | EventMask::FOCUS_CHANGE
3299 | EventMask::PROPERTY_CHANGE
3300 | EventMask::STRUCTURE_NOTIFY
3301 | EventMask::POINTER_MOTION,
3302 )?;
3303
3304 // Don't grab buttons - the window is already focused (we're acting on focused window)
3305 // and grabbing would intercept all clicks, preventing app interaction.
3306 // Mod+button grabs on root handle floating move/resize.
3307 // Button grabs are only for click-to-focus on unfocused windows.
3308
3309 // Add to floating list (on top)
3310 self.current_workspace_mut().add_floating(window);
3311
3312 // Re-apply layout (this will configure the floating window and stack it)
3313 self.apply_layout()?;
3314 }
3315
3316 Ok(())
3317 }
3318
3319 // =========================================================================
3320 // i3-compatible IPC handling (for polybar integration)
3321 // =========================================================================
3322
3323 /// Handle pending i3-compatible IPC requests
3324 fn handle_i3_ipc(&mut self) -> Result<()> {
3325 use crate::ipc::i3_compat::MessageType;
3326 use crate::ipc::i3_server::{
3327 build_workspaces_json, build_outputs_json, build_version_json,
3328 build_subscribe_success_json,
3329 };
3330
3331 let Some(ref mut i3_ipc) = self.i3_ipc_server else {
3332 return Ok(());
3333 };
3334
3335 // Accept new connections
3336 i3_ipc.accept_connections();
3337
3338 // Process requests
3339 let requests = i3_ipc.poll_requests();
3340 for (client_idx, msg) in requests {
3341 let msg_type = msg.msg_type;
3342
3343 match MessageType::from_u32(msg_type) {
3344 Some(MessageType::GetWorkspaces) => {
3345 let workspaces = self.build_i3_workspaces();
3346 let focused_ws: Vec<_> = workspaces.iter().filter(|w| w.focused).map(|w| &w.name).collect();
3347 tracing::debug!("GET_WORKSPACES: returning {} workspaces, focused: {:?}", workspaces.len(), focused_ws);
3348 let json = build_workspaces_json(&workspaces);
3349 if let Some(ref mut i3_ipc) = self.i3_ipc_server {
3350 i3_ipc.send_response(client_idx, msg_type, &json);
3351 }
3352 }
3353 Some(MessageType::GetOutputs) => {
3354 let outputs = self.build_i3_outputs();
3355 let json = build_outputs_json(&outputs);
3356 if let Some(ref mut i3_ipc) = self.i3_ipc_server {
3357 i3_ipc.send_response(client_idx, msg_type, &json);
3358 }
3359 }
3360 Some(MessageType::Subscribe) => {
3361 // Parse subscription request
3362 if let Ok(events) = msg.payload_str() {
3363 if let Ok(event_list) = serde_json::from_str::<Vec<String>>(events) {
3364 if let Some(ref mut i3_ipc) = self.i3_ipc_server {
3365 i3_ipc.subscribe(client_idx, event_list);
3366 }
3367 }
3368 }
3369 let json = build_subscribe_success_json();
3370 if let Some(ref mut i3_ipc) = self.i3_ipc_server {
3371 i3_ipc.send_response(client_idx, msg_type, &json);
3372 }
3373 }
3374 Some(MessageType::GetVersion) => {
3375 let json = build_version_json();
3376 if let Some(ref mut i3_ipc) = self.i3_ipc_server {
3377 i3_ipc.send_response(client_idx, msg_type, &json);
3378 }
3379 }
3380 Some(MessageType::RunCommand) => {
3381 // Parse and execute i3-compatible commands
3382 let cmd_str = msg.payload_str().unwrap_or("");
3383 let success = self.execute_i3_command(cmd_str);
3384 let json = if success {
3385 r#"[{"success":true}]"#
3386 } else {
3387 r#"[{"success":false,"error":"command failed"}]"#
3388 };
3389 if let Some(ref mut i3_ipc) = self.i3_ipc_server {
3390 i3_ipc.send_response(client_idx, msg_type, json);
3391 }
3392 }
3393 _ => {
3394 // Unknown or unsupported message type - return empty success
3395 let json = r#"{"success":false,"error":"unsupported"}"#;
3396 if let Some(ref mut i3_ipc) = self.i3_ipc_server {
3397 i3_ipc.send_response(client_idx, msg_type, json);
3398 }
3399 }
3400 }
3401 }
3402
3403 // Clean up disconnected/stale clients AFTER processing requests
3404 // to avoid index invalidation during send_response
3405 if let Some(ref mut i3_ipc) = self.i3_ipc_server {
3406 i3_ipc.cleanup_clients();
3407 }
3408
3409 Ok(())
3410 }
3411
3412 /// Build i3-compatible workspace list
3413 /// Only includes workspaces that are visible or have windows (like i3)
3414 fn build_i3_workspaces(&self) -> Vec<crate::ipc::I3WorkspaceInfo> {
3415 use crate::ipc::{I3WorkspaceInfo, I3Rect};
3416
3417 self.workspaces.iter().enumerate().filter_map(|(i, ws)| {
3418 // Find which monitor this workspace is on (if visible)
3419 let monitor = self.monitors.iter().find(|m| m.active_workspace == i);
3420 let visible = monitor.is_some();
3421 let has_windows = ws.has_windows();
3422
3423 // Only include workspaces that are visible OR have windows
3424 if !visible && !has_windows {
3425 return None;
3426 }
3427
3428 let focused = self.focused_monitor < self.monitors.len()
3429 && self.monitors[self.focused_monitor].active_workspace == i;
3430
3431 // Check if any window in this workspace is urgent
3432 let urgent = self.windows.values()
3433 .filter(|w| w.workspace == i)
3434 .any(|w| w.urgent);
3435
3436 // Get geometry and output from monitor
3437 // For non-visible workspaces with windows, assign to focused monitor
3438 let (rect, output) = if let Some(mon) = monitor {
3439 (
3440 I3Rect {
3441 x: mon.geometry.x as i32,
3442 y: mon.geometry.y as i32,
3443 width: mon.geometry.width as i32,
3444 height: mon.geometry.height as i32,
3445 },
3446 mon.name.clone(),
3447 )
3448 } else {
3449 // Not visible but has windows - assign to focused monitor
3450 let mon = &self.monitors[self.focused_monitor];
3451 (
3452 I3Rect {
3453 x: mon.geometry.x as i32,
3454 y: mon.geometry.y as i32,
3455 width: mon.geometry.width as i32,
3456 height: mon.geometry.height as i32,
3457 },
3458 mon.name.clone(),
3459 )
3460 };
3461
3462 Some(I3WorkspaceInfo {
3463 id: (i + 1) as i64 * 1000000, // Generate unique ID
3464 num: (i + 1) as i32,
3465 name: ws.name.clone(),
3466 visible,
3467 focused,
3468 urgent,
3469 rect,
3470 output,
3471 })
3472 }).collect()
3473 }
3474
3475 /// Build i3-compatible output list
3476 fn build_i3_outputs(&self) -> Vec<crate::ipc::OutputInfo> {
3477 use crate::ipc::{OutputInfo, I3Rect};
3478
3479 self.monitors.iter().map(|mon| {
3480 let current_workspace = Some(self.workspaces[mon.active_workspace].name.clone());
3481
3482 OutputInfo {
3483 name: mon.name.clone(),
3484 active: true,
3485 primary: mon.primary,
3486 current_workspace,
3487 rect: I3Rect {
3488 x: mon.geometry.x as i32,
3489 y: mon.geometry.y as i32,
3490 width: mon.geometry.width as i32,
3491 height: mon.geometry.height as i32,
3492 },
3493 }
3494 }).collect()
3495 }
3496
3497 /// Broadcast i3 workspace event to subscribed clients
3498 pub fn broadcast_i3_workspace_event(&mut self, change: &str, workspace_idx: usize, old_workspace_idx: Option<usize>) {
3499 use crate::ipc::i3_server::build_workspace_event_json;
3500
3501 let workspaces = self.build_i3_workspaces();
3502
3503 // Find workspace by num (workspace_idx + 1), not by array index
3504 // build_i3_workspaces filters out empty/invisible workspaces so indices don't match
3505 let workspace_num = (workspace_idx + 1) as i32;
3506 let current = workspaces.iter().find(|w| w.num == workspace_num).cloned();
3507 let old = old_workspace_idx.and_then(|idx| {
3508 let old_num = (idx + 1) as i32;
3509 workspaces.iter().find(|w| w.num == old_num).cloned()
3510 });
3511
3512 if let Some(current) = current {
3513 let json = build_workspace_event_json(change, &current, old.as_ref());
3514 tracing::debug!("Broadcasting i3 workspace event: change={}, workspace={}", change, current.num);
3515 if let Some(ref mut i3_ipc) = self.i3_ipc_server {
3516 i3_ipc.broadcast_workspace_event(&json);
3517 }
3518 } else {
3519 tracing::warn!("Could not find workspace {} in i3 workspace list for broadcast", workspace_num);
3520 }
3521 }
3522
3523 /// Broadcast i3 output event to subscribed clients
3524 pub fn broadcast_i3_output_event(&mut self) {
3525 use crate::ipc::i3_server::build_output_event_json;
3526
3527 let json = build_output_event_json();
3528 if let Some(ref mut i3_ipc) = self.i3_ipc_server {
3529 i3_ipc.broadcast_output_event(&json);
3530 }
3531 }
3532 }
3533
3534 fn parse_direction(s: &str) -> Option<Direction> {
3535 match s.to_lowercase().as_str() {
3536 "left" => Some(Direction::Left),
3537 "right" => Some(Direction::Right),
3538 "up" => Some(Direction::Up),
3539 "down" => Some(Direction::Down),
3540 _ => None,
3541 }
3542 }
3543
3544 /// Threshold in pixels for detecting edge proximity
3545 const EDGE_THRESHOLD: i16 = 12;
3546
3547 /// Determine which edge/corner of a window a point is near.
3548 /// Returns ResizeEdge::None if not near any edge.
3549 fn determine_resize_edge(geometry: &Rect, click_x: i16, click_y: i16) -> ResizeEdge {
3550 let left = geometry.x;
3551 let right = geometry.x + geometry.width as i16;
3552 let top = geometry.y;
3553 let bottom = geometry.y + geometry.height as i16;
3554
3555 let near_left = click_x >= left && click_x < left + EDGE_THRESHOLD;
3556 let near_right = click_x > right - EDGE_THRESHOLD && click_x <= right;
3557 let near_top = click_y >= top && click_y < top + EDGE_THRESHOLD;
3558 let near_bottom = click_y > bottom - EDGE_THRESHOLD && click_y <= bottom;
3559
3560 match (near_left, near_right, near_top, near_bottom) {
3561 (true, _, true, _) => ResizeEdge::TopLeft,
3562 (_, true, true, _) => ResizeEdge::TopRight,
3563 (true, _, _, true) => ResizeEdge::BottomLeft,
3564 (_, true, _, true) => ResizeEdge::BottomRight,
3565 (true, _, _, _) => ResizeEdge::Left,
3566 (_, true, _, _) => ResizeEdge::Right,
3567 (_, _, true, _) => ResizeEdge::Top,
3568 (_, _, _, true) => ResizeEdge::Bottom,
3569 _ => ResizeEdge::None,
3570 }
3571 }
3572
3573 /// Determine resize edge for mod+click (quadrant-based, always picks a corner)
3574 fn determine_resize_edge_quadrant(geometry: &Rect, click_x: i16, click_y: i16) -> ResizeEdge {
3575 let center_x = geometry.x + geometry.width as i16 / 2;
3576 let center_y = geometry.y + geometry.height as i16 / 2;
3577
3578 match (click_x < center_x, click_y < center_y) {
3579 (true, true) => ResizeEdge::TopLeft,
3580 (false, true) => ResizeEdge::TopRight,
3581 (true, false) => ResizeEdge::BottomLeft,
3582 (false, false) => ResizeEdge::BottomRight,
3583 }
3584 }
3585
3586 fn calculate_resize(geometry: &Rect, edge: ResizeEdge, dx: i16, dy: i16) -> (i16, i16, u16, u16) {
3587 const MIN_SIZE: u16 = 50;
3588
3589 let (mut x, mut y, mut w, mut h) = (
3590 geometry.x,
3591 geometry.y,
3592 geometry.width,
3593 geometry.height,
3594 );
3595
3596 match edge {
3597 ResizeEdge::TopLeft => {
3598 x += dx;
3599 y += dy;
3600 w = (w as i16 - dx).max(MIN_SIZE as i16) as u16;
3601 h = (h as i16 - dy).max(MIN_SIZE as i16) as u16;
3602 }
3603 ResizeEdge::Top => {
3604 y += dy;
3605 h = (h as i16 - dy).max(MIN_SIZE as i16) as u16;
3606 }
3607 ResizeEdge::TopRight => {
3608 y += dy;
3609 w = (w as i16 + dx).max(MIN_SIZE as i16) as u16;
3610 h = (h as i16 - dy).max(MIN_SIZE as i16) as u16;
3611 }
3612 ResizeEdge::Left => {
3613 x += dx;
3614 w = (w as i16 - dx).max(MIN_SIZE as i16) as u16;
3615 }
3616 ResizeEdge::Right => {
3617 w = (w as i16 + dx).max(MIN_SIZE as i16) as u16;
3618 }
3619 ResizeEdge::BottomLeft => {
3620 x += dx;
3621 w = (w as i16 - dx).max(MIN_SIZE as i16) as u16;
3622 h = (h as i16 + dy).max(MIN_SIZE as i16) as u16;
3623 }
3624 ResizeEdge::Bottom => {
3625 h = (h as i16 + dy).max(MIN_SIZE as i16) as u16;
3626 }
3627 ResizeEdge::BottomRight => {
3628 w = (w as i16 + dx).max(MIN_SIZE as i16) as u16;
3629 h = (h as i16 + dy).max(MIN_SIZE as i16) as u16;
3630 }
3631 ResizeEdge::None => {
3632 // No resize
3633 }
3634 }
3635
3636 (x, y, w, h)
3637 }
3638