diff --git a/README.md b/README.md index 4bb7ebb..da1fd22 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ cargo build --no-default-features Skew is inspired by and builds upon ideas from: -- **[sway](https://swaywm.org/)**: A Wayland compositor and i3-compatible window manager for Linux +- **[sway](https://swaywm.org/)**: A Wayland compositor and i3 compatible window manager for Linux - **[yabai](https://github.com/koekeishiya/yabai)**: A tiling window manager for macOS based on binary space partitioning - **[i3wm](https://i3wm.org/)**: The foundational tiling window manager that influenced many others diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..9761611 --- /dev/null +++ b/llms.txt @@ -0,0 +1,81 @@ +# Skew - macOS Tiling Window Manager + +## Project Overview + +Skew is a production-ready tiling window manager for macOS written in Rust. It provides advanced window management through 7 different layout algorithms and deep macOS integration via Accessibility APIs. + +## Key Components + +- **CLI Interface** (`main.rs`): Command-line entry point +- **Daemon Service** (`daemon.rs`): Background window management service +- **Window Manager** (`window_manager.rs`): Core window control logic +- **Layout Engine** (`layout.rs`): 7 tiling algorithms (BSP, Stack, Grid, Spiral, Column, Monocle, Float) +- **Focus Management** (`focus.rs`): Smart window focus and navigation +- **Hotkey System** (`hotkeys.rs`): Global keyboard shortcuts +- **IPC System** (`ipc.rs`): CLI-daemon communication +- **Plugin System** (`plugins.rs`): Lua scripting integration +- **macOS Integration** (`macos/`): Accessibility API bindings, Core Graphics window ops + +## Architecture + +**Binaries:** `skew` (CLI), `skewd` (daemon), `skew-cli` (utilities) + +**Key Dependencies:** +- macOS APIs: core-graphics, core-foundation, cocoa, objc +- Async: tokio +- Config: serde, toml, serde_json, serde_yaml +- Hotkeys: rdev +- Plugins: mlua (optional) +- CLI: clap + +## Development Workflow + +**Critical:** After making any changes, always run these commands in sequence: + +```bash +cargo fmt # Format code +cargo clippy # Run lints +cargo check # Type check +``` + +**Testing:** +```bash +cargo test # Run test suite +cargo build # Debug build +cargo build --release # Production build +``` + +**Features:** +- `default`: Includes scripting support +- `scripting`: Lua plugin system + +## macOS-Specific Considerations + +- Requires Accessibility API permissions to function +- Uses Core Graphics for window operations +- Integrates with macOS window system through Accessibility bindings +- Hot-key handling works system-wide via rdev + +## Code Style + +- Standard Rust formatting (cargo fmt) +- Follow existing patterns in the codebase +- Use comprehensive error handling with anyhow/thiserror +- Async code uses tokio runtime +- Configuration supports TOML, JSON, and YAML + +## Common Operations + +- Window management through Accessibility APIs +- Layout algorithms operate on window geometry +- IPC communication uses Unix sockets +- Plugin system allows Lua scripting extensions +- Hotkey bindings are configurable via TOML + +## Important Notes + +- This is production code - changes should be thoroughly tested +- Accessibility permissions are critical for functionality +- Multi-display support is built-in +- Configuration is extensively validated +- Error handling and logging are comprehensive throughout \ No newline at end of file diff --git a/src/bin/skew-cli.rs b/src/bin/skew-cli.rs index 6b7a9c7..808e68e 100644 --- a/src/bin/skew-cli.rs +++ b/src/bin/skew-cli.rs @@ -1,5 +1,4 @@ use skew::ipc::IpcClient; -use tokio; #[tokio::main] async fn main() -> skew::Result<()> { diff --git a/src/config.rs b/src/config.rs index ac59e39..47e1ffb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -305,7 +305,7 @@ impl HotkeyConfig { } let parts: Vec<&str> = key_combo.split('+').collect(); - if parts.len() < 1 { + if parts.is_empty() { return Err(anyhow::anyhow!( "Invalid key combination format: '{}'", key_combo diff --git a/src/focus.rs b/src/focus.rs index 62d0a9c..9dcd5f9 100644 --- a/src/focus.rs +++ b/src/focus.rs @@ -338,12 +338,10 @@ impl FocusManager { Some(current_index) => { if forward { (current_index + 1) % focusable_windows.len() + } else if current_index == 0 { + focusable_windows.len() - 1 } else { - if current_index == 0 { - focusable_windows.len() - 1 - } else { - current_index - 1 - } + current_index - 1 } } None => 0, // No window focused, start with first diff --git a/src/hotkeys.rs b/src/hotkeys.rs index 7333309..4a88e93 100644 --- a/src/hotkeys.rs +++ b/src/hotkeys.rs @@ -55,7 +55,7 @@ impl HotkeyManager { // Create channel for rdev events let (event_sender, event_receiver) = std::sync::mpsc::channel(); - + // Set global sender (this can only be done once) if GLOBAL_HOTKEY_SENDER.set(event_sender).is_err() { warn!("Global hotkey sender already initialized"); @@ -84,7 +84,9 @@ impl HotkeyManager { drop(running); // Take the event receiver (can only be done once) - let event_receiver = self.event_receiver.take() + let event_receiver = self + .event_receiver + .take() .ok_or_else(|| anyhow::anyhow!("Event receiver already taken"))?; // Clone necessary data for the background tasks @@ -179,17 +181,15 @@ impl HotkeyManager { is_running: Arc>, ) { info!("Starting hotkey event processing"); - + while *is_running.lock().unwrap() { // Use a timeout to periodically check if we should stop match event_receiver.recv_timeout(std::time::Duration::from_millis(100)) { Ok(event) => { - if let Err(e) = Self::handle_rdev_event( - event, - &bindings, - &command_sender, - &pressed_keys, - ).await { + if let Err(e) = + Self::handle_rdev_event(event, &bindings, &command_sender, &pressed_keys) + .await + { error!("Error handling hotkey event: {}", e); } } @@ -203,10 +203,10 @@ impl HotkeyManager { } } } - + info!("Hotkey event processing stopped"); } - + async fn handle_rdev_event( event: rdev::Event, bindings: &HashMap, @@ -218,11 +218,14 @@ impl HotkeyManager { debug!("Key pressed: {:?}", key); { let mut keys = pressed_keys.lock().unwrap(); - if !keys.iter().any(|k| std::mem::discriminant(k) == std::mem::discriminant(&key)) { + if !keys + .iter() + .any(|k| std::mem::discriminant(k) == std::mem::discriminant(&key)) + { keys.push(key); } } - + // Check for matching key combinations let keys = pressed_keys.lock().unwrap().clone(); if let Some(combination) = Self::match_key_combination(&keys, bindings) { @@ -244,17 +247,16 @@ impl HotkeyManager { } _ => {} // Ignore other event types } - + Ok(()) } - fn match_key_combination( pressed_keys: &[Key], bindings: &HashMap, ) -> Option { - for (combination, _) in bindings { - if Self::is_combination_pressed(&combination, pressed_keys) { + for combination in bindings.keys() { + if Self::is_combination_pressed(combination, pressed_keys) { return Some(combination.clone()); } } @@ -263,31 +265,36 @@ impl HotkeyManager { fn is_combination_pressed(combination: &KeyCombination, pressed_keys: &[Key]) -> bool { fn key_is_pressed(keys: &[Key], target: &Key) -> bool { - keys.iter().any(|k| std::mem::discriminant(k) == std::mem::discriminant(target)) + keys.iter() + .any(|k| std::mem::discriminant(k) == std::mem::discriminant(target)) } for modifier in &combination.modifiers { match modifier { ModifierKey::Alt => { - if !key_is_pressed(pressed_keys, &Key::Alt) && - !key_is_pressed(pressed_keys, &Key::AltGr) { + if !key_is_pressed(pressed_keys, &Key::Alt) + && !key_is_pressed(pressed_keys, &Key::AltGr) + { return false; } } ModifierKey::Ctrl => { - if !key_is_pressed(pressed_keys, &Key::ControlLeft) && - !key_is_pressed(pressed_keys, &Key::ControlRight) { + if !key_is_pressed(pressed_keys, &Key::ControlLeft) + && !key_is_pressed(pressed_keys, &Key::ControlRight) + { return false; } } ModifierKey::Shift => { - if !key_is_pressed(pressed_keys, &Key::ShiftLeft) && - !key_is_pressed(pressed_keys, &Key::ShiftRight) { + if !key_is_pressed(pressed_keys, &Key::ShiftLeft) + && !key_is_pressed(pressed_keys, &Key::ShiftRight) + { return false; } } ModifierKey::Cmd => { - if !key_is_pressed(pressed_keys, &Key::MetaLeft) && - !key_is_pressed(pressed_keys, &Key::MetaRight) { + if !key_is_pressed(pressed_keys, &Key::MetaLeft) + && !key_is_pressed(pressed_keys, &Key::MetaRight) + { return false; } } @@ -405,7 +412,6 @@ impl HotkeyManager { } fn parse_action(action: &str) -> Result { - let parts: Vec<&str> = action.split(':').collect(); let command = parts[0]; @@ -458,7 +464,7 @@ impl HotkeyManager { // Global callback function for rdev - must be a function pointer fn global_hotkey_callback(event: Event) { if let Some(sender) = GLOBAL_HOTKEY_SENDER.get() { - if let Err(_) = sender.send(event) { + if sender.send(event).is_err() { // Channel is closed, ignore the error } } diff --git a/src/ipc.rs b/src/ipc.rs index 38ee83c..c2a7d87 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -150,7 +150,7 @@ impl IpcServer { let command = match message.command.as_str() { "focus" => { - if let Some(id_str) = message.args.get(0) { + if let Some(id_str) = message.args.first() { match id_str.parse::() { Ok(id) => Command::FocusWindow(WindowId(id)), Err(_) => { @@ -170,7 +170,7 @@ impl IpcServer { } } "close" => { - if let Some(id_str) = message.args.get(0) { + if let Some(id_str) = message.args.first() { match id_str.parse::() { Ok(id) => Command::CloseWindow(WindowId(id)), Err(_) => { diff --git a/src/layout.rs b/src/layout.rs index aa00390..5c20da4 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -2,7 +2,7 @@ use crate::config::{GeneralConfig, LayoutConfig}; use crate::{Rect, Window, WindowId}; use std::collections::HashMap; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub enum LayoutType { BSP, Stack, @@ -77,55 +77,161 @@ impl BSPNode { self.left.is_none() && self.right.is_none() } - pub fn insert_window(&mut self, window_id: WindowId, split_ratio: f64) { - if self.is_leaf() { - if let Some(existing_id) = self.window_id { - let left_rect = if self.is_horizontal { - Rect::new( - self.rect.x, - self.rect.y, - self.rect.width * self.split_ratio, - self.rect.height, - ) - } else { - Rect::new( - self.rect.x, - self.rect.y, - self.rect.width, - self.rect.height * self.split_ratio, - ) - }; + pub fn update_rect(&mut self, rect: Rect) { + self.rect = rect; + self.update_child_rects(); + } - let right_rect = if self.is_horizontal { + fn update_child_rects(&mut self) { + if let (Some(ref mut left), Some(ref mut right)) = (&mut self.left, &mut self.right) { + let (left_rect, right_rect) = if self.is_horizontal { + let left_width = self.rect.width * self.split_ratio; + let right_width = self.rect.width - left_width; + ( + Rect::new(self.rect.x, self.rect.y, left_width, self.rect.height), Rect::new( - self.rect.x + left_rect.width, + self.rect.x + left_width, self.rect.y, - self.rect.width - left_rect.width, + right_width, self.rect.height, - ) - } else { + ), + ) + } else { + let left_height = self.rect.height * self.split_ratio; + let right_height = self.rect.height - left_height; + ( + Rect::new(self.rect.x, self.rect.y, self.rect.width, left_height), Rect::new( self.rect.x, - self.rect.y + left_rect.height, + self.rect.y + left_height, self.rect.width, - self.rect.height - left_rect.height, - ) - }; + right_height, + ), + ) + }; + left.update_rect(left_rect); + right.update_rect(right_rect); + } + } + + pub fn insert_window(&mut self, window_id: WindowId, split_ratio: f64) { + self.insert_window_with_depth(window_id, split_ratio, 0); + } + + fn insert_window_with_depth(&mut self, window_id: WindowId, split_ratio: f64, depth: usize) { + if self.is_leaf() { + if let Some(existing_id) = self.window_id { + // For spiral layout: First split is vertical (horizontal = true), then alternate + // This creates the i3/sway-like pattern where new windows go right, then down + let should_split_horizontal = depth % 2 == 0; - self.left = Some(Box::new(BSPNode::new_leaf(existing_id, left_rect))); - self.right = Some(Box::new(BSPNode::new_leaf(window_id, right_rect))); + // Convert this leaf into a container self.window_id = None; self.split_ratio = split_ratio; + self.is_horizontal = should_split_horizontal; + + // Create child nodes - put existing window on left/top, new window on right/bottom + self.left = Some(Box::new(BSPNode::new_leaf( + existing_id, + Rect::new(0.0, 0.0, 0.0, 0.0), + ))); + self.right = Some(Box::new(BSPNode::new_leaf( + window_id, + Rect::new(0.0, 0.0, 0.0, 0.0), + ))); + + // Update rects for all children + self.update_child_rects(); } else { self.window_id = Some(window_id); } } else { + // For spiral behavior, always insert into the rightmost/bottommost position + // This creates the spiral downward/rightward pattern if let Some(ref mut right) = self.right { - right.insert_window(window_id, split_ratio); + right.insert_window_with_depth(window_id, split_ratio, depth + 1); + } else if let Some(ref mut left) = self.left { + left.insert_window_with_depth(window_id, split_ratio, depth + 1); + } + } + } + + fn count_windows(&self) -> usize { + if self.is_leaf() { + if self.window_id.is_some() { + 1 + } else { + 0 + } + } else { + let left_count = self.left.as_ref().map_or(0, |n| n.count_windows()); + let right_count = self.right.as_ref().map_or(0, |n| n.count_windows()); + left_count + right_count + } + } + + pub fn remove_window(&mut self, window_id: WindowId) -> bool { + if self.is_leaf() { + if self.window_id == Some(window_id) { + self.window_id = None; + return true; + } + return false; + } + + let removed_from_left = self + .left + .as_mut() + .is_some_and(|left| left.remove_window(window_id)); + let removed_from_right = self + .right + .as_mut() + .is_some_and(|right| right.remove_window(window_id)); + + if removed_from_left || removed_from_right { + self.collapse_if_needed(); + return true; + } + + false + } + + fn collapse_if_needed(&mut self) { + let left_empty = self.left.as_ref().is_none_or(|n| n.count_windows() == 0); + let right_empty = self.right.as_ref().is_none_or(|n| n.count_windows() == 0); + + if left_empty && right_empty { + // Both children are empty, this becomes an empty leaf + self.left = None; + self.right = None; + self.window_id = None; + } else if left_empty { + // Left is empty, promote right child + if let Some(right) = self.right.take() { + *self = *right; + } + } else if right_empty { + // Right is empty, promote left child + if let Some(left) = self.left.take() { + *self = *left; } } } + pub fn contains_window(&self, window_id: WindowId) -> bool { + if self.is_leaf() { + return self.window_id == Some(window_id); + } + + self.left + .as_ref() + .is_some_and(|left| left.contains_window(window_id)) + || self + .right + .as_ref() + .is_some_and(|right| right.contains_window(window_id)) + } + pub fn collect_window_rects(&self, gap: f64) -> HashMap { let mut rects = HashMap::new(); self.collect_rects_recursive(&mut rects, gap); @@ -167,6 +273,30 @@ impl LayoutManager { } } + pub fn add_window(&mut self, window_id: WindowId, screen_rect: Rect) { + if self.current_layout == LayoutType::BSP { + if let Some(ref mut root) = self.bsp_root { + root.insert_window(window_id, self.split_ratio); + root.update_rect(screen_rect); + } else { + self.bsp_root = Some(BSPNode::new_leaf(window_id, screen_rect)); + } + } + } + + pub fn remove_window(&mut self, window_id: WindowId, screen_rect: Rect) { + if self.current_layout == LayoutType::BSP { + if let Some(ref mut root) = self.bsp_root { + root.remove_window(window_id); + if root.count_windows() == 0 { + self.bsp_root = None; + } else { + root.update_rect(screen_rect); + } + } + } + } + pub fn compute_layout( &mut self, windows: &[&Window], @@ -193,17 +323,67 @@ impl LayoutManager { general_config: &GeneralConfig, ) -> HashMap { if windows.is_empty() { + self.bsp_root = None; return HashMap::new(); } - let mut root = BSPNode::new_leaf(windows[0].id, screen_rect); + let window_ids: std::collections::HashSet = + windows.iter().map(|w| w.id).collect(); + + // Sync the BSP tree with current windows + if let Some(ref mut root) = self.bsp_root { + // Remove windows that are no longer present + let mut to_remove = Vec::new(); + Self::collect_all_windows_static(root, &mut to_remove); + for window_id in to_remove { + if !window_ids.contains(&window_id) { + root.remove_window(window_id); + } + } + + // Add new windows + for window in windows { + if !root.contains_window(window.id) { + root.insert_window(window.id, self.split_ratio); + } + } + + // Update the tree rect + root.update_rect(screen_rect); - for window in windows.iter().skip(1) { - root.insert_window(window.id, self.split_ratio); + // If tree is now empty, reset it + if root.count_windows() == 0 { + self.bsp_root = None; + } } - self.bsp_root = Some(root.clone()); - root.collect_window_rects(general_config.gap) + // If no tree exists or tree is empty, create new tree + if self.bsp_root.is_none() { + let mut root = BSPNode::new_leaf(windows[0].id, screen_rect); + for window in windows.iter().skip(1) { + root.insert_window(window.id, self.split_ratio); + } + self.bsp_root = Some(root); + } + + // Return layout from the tree + if let Some(ref root) = self.bsp_root { + root.collect_window_rects(general_config.gap) + } else { + HashMap::new() + } + } + + fn collect_all_windows_static(node: &BSPNode, windows: &mut Vec) { + if let Some(window_id) = node.window_id { + windows.push(window_id); + } + if let Some(ref left) = node.left { + Self::collect_all_windows_static(left, windows); + } + if let Some(ref right) = node.right { + Self::collect_all_windows_static(right, windows); + } } fn compute_stack_layout( @@ -260,7 +440,7 @@ impl LayoutManager { _screen_rect: Rect, _general_config: &GeneralConfig, ) -> HashMap { - windows.iter().map(|w| (w.id, w.rect.clone())).collect() + windows.iter().map(|w| (w.id, w.rect)).collect() } fn compute_grid_layout( @@ -277,7 +457,7 @@ impl LayoutManager { let window_count = windows.len(); let cols = (window_count as f64).sqrt().ceil() as usize; - let rows = (window_count + cols - 1) / cols; + let rows = window_count.div_ceil(cols); let cell_width = (screen_rect.width - general_config.gap * (cols + 1) as f64) / cols as f64; let cell_height = @@ -399,7 +579,7 @@ impl LayoutManager { ); for window in windows { - rects.insert(window.id, fullscreen_rect.clone()); + rects.insert(window.id, fullscreen_rect); } rects @@ -416,13 +596,13 @@ impl LayoutManager { LayoutType::Float => LayoutType::BSP, }; } - + pub fn get_current_layout(&self) -> &LayoutType { &self.current_layout } pub fn adjust_split_ratio(&mut self, delta: f64) { - self.split_ratio = (self.split_ratio + delta).max(0.1).min(0.9); + self.split_ratio = (self.split_ratio + delta).clamp(0.1, 0.9); } pub fn get_split_ratio(&self) -> f64 { diff --git a/src/lib.rs b/src/lib.rs index 6de9ca2..55af2ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod ipc; pub mod layout; pub mod macos; pub mod plugins; +pub mod snap; pub mod window_manager; pub use config::Config; diff --git a/src/macos/accessibility.rs b/src/macos/accessibility.rs index c653f5f..908028a 100644 --- a/src/macos/accessibility.rs +++ b/src/macos/accessibility.rs @@ -1,8 +1,18 @@ use crate::{Rect, Result, WindowId}; -use core_foundation::base::{CFRelease, CFTypeRef, TCFType}; +use core_foundation::array::{CFArrayGetCount, CFArrayGetValueAtIndex, CFArrayRef}; +use core_foundation::base::{CFRelease, CFRetain, CFTypeRef, TCFType}; use core_foundation::string::{CFString, CFStringRef}; use log::{debug, info, warn}; use std::collections::HashMap; +use std::os::raw::{c_double, c_int, c_void}; + +// Additional system calls for process enumeration +extern "C" { + fn proc_listpids(proc_type: u32, typeinfo: u32, buffer: *mut c_int, buffersize: c_int) + -> c_int; +} + +const PROC_ALL_PIDS: u32 = 1; #[link(name = "ApplicationServices", kind = "framework")] extern "C" { @@ -18,29 +28,42 @@ extern "C" { attribute: CFStringRef, value: CFTypeRef, ) -> AXError; - fn AXUIElementCopyElementAtPosition( - element: AXUIElementRef, - x: f32, - y: f32, - element_at_position: *mut AXUIElementRef, - ) -> AXError; fn AXUIElementGetPid(element: AXUIElementRef, pid: *mut i32) -> AXError; fn AXIsProcessTrusted() -> bool; fn AXUIElementPerformAction(element: AXUIElementRef, action: CFStringRef) -> AXError; + + // Core Foundation value creation functions + fn AXValueCreate(value_type: AXValueType, value_ptr: *const c_void) -> CFTypeRef; + fn AXValueGetValue(value: CFTypeRef, value_type: AXValueType, value_ptr: *mut c_void) -> bool; +} + +type AXValueType = u32; +const K_AXVALUE_CGPOINT_TYPE: AXValueType = 1; +const K_AXVALUE_CGSIZE_TYPE: AXValueType = 2; + +#[repr(C)] +struct CGPoint { + x: c_double, + y: c_double, +} + +#[repr(C)] +struct CGSize { + width: c_double, + height: c_double, } type AXUIElementRef = CFTypeRef; type AXError = i32; -const kAXErrorSuccess: AXError = 0; -const kAXFocusedApplicationAttribute: &str = "AXFocusedApplication"; -const kAXFocusedWindowAttribute: &str = "AXFocusedWindow"; -const kAXPositionAttribute: &str = "AXPosition"; -const kAXSizeAttribute: &str = "AXSize"; -const kAXWindowsAttribute: &str = "AXWindows"; -const kAXTitleAttribute: &str = "AXTitle"; -const kAXRaiseAction: &str = "AXRaise"; -const kAXPressAction: &str = "AXPress"; +const K_AXERROR_SUCCESS: AXError = 0; +const K_AXFOCUSED_APPLICATION_ATTRIBUTE: &str = "AXFocusedApplication"; +const K_AXFOCUSED_WINDOW_ATTRIBUTE: &str = "AXFocusedWindow"; +const K_AXPOSITION_ATTRIBUTE: &str = "AXPosition"; +const K_AXSIZE_ATTRIBUTE: &str = "AXSize"; +const K_AXWINDOWS_ATTRIBUTE: &str = "AXWindows"; +const K_AXRAISE_ACTION: &str = "AXRaise"; +const K_AXPRESS_ACTION: &str = "AXPress"; pub struct AccessibilityManager { system_element: AXUIElementRef, @@ -74,7 +97,7 @@ impl AccessibilityManager { debug!("Getting focused window via Accessibility API"); unsafe { - let focused_app_attr = CFString::new(kAXFocusedApplicationAttribute); + let focused_app_attr = CFString::new(K_AXFOCUSED_APPLICATION_ATTRIBUTE); let mut focused_app: CFTypeRef = std::ptr::null_mut(); let result = AXUIElementCopyAttributeValue( @@ -83,12 +106,12 @@ impl AccessibilityManager { &mut focused_app, ); - if result != kAXErrorSuccess { + if result != K_AXERROR_SUCCESS { debug!("Failed to get focused application: {}", result); return Ok(None); } - let focused_window_attr = CFString::new(kAXFocusedWindowAttribute); + let focused_window_attr = CFString::new(K_AXFOCUSED_WINDOW_ATTRIBUTE); let mut focused_window: CFTypeRef = std::ptr::null_mut(); let result = AXUIElementCopyAttributeValue( @@ -99,7 +122,7 @@ impl AccessibilityManager { CFRelease(focused_app); - if result != kAXErrorSuccess { + if result != K_AXERROR_SUCCESS { debug!("Failed to get focused window: {}", result); return Ok(None); } @@ -108,8 +131,8 @@ impl AccessibilityManager { let mut pid: i32 = 0; AXUIElementGetPid(focused_window, &mut pid); - // Create a window ID from the memory address (simple approach) - let window_id = WindowId(focused_window as u32); + // Create a stable window ID using hash of element pointer, PID, and timestamp + let window_id = self.generate_window_id(focused_window, pid, None); CFRelease(focused_window); @@ -129,40 +152,475 @@ impl AccessibilityManager { if let Some((_pid, element)) = self.window_cache.get(&window_id) { unsafe { - let raise_action = CFString::new(kAXRaiseAction); + let raise_action = CFString::new(K_AXRAISE_ACTION); let result = AXUIElementPerformAction(*element, raise_action.as_concrete_TypeRef()); - if result == kAXErrorSuccess { + if result == K_AXERROR_SUCCESS { debug!("Successfully focused window {:?}", window_id); } else { warn!("Failed to focus window {:?}: error {}", window_id, result); } } } else { - debug!("Window {:?} not found in accessibility cache - may be a non-manageable window", window_id); + debug!( + "Window {:?} not found in accessibility cache - may be a non-manageable window", + window_id + ); } Ok(()) } - pub fn move_window(&mut self, window_id: WindowId, _rect: Rect) -> Result<()> { - debug!("Moving window {:?} via Accessibility API", window_id); + pub fn move_window(&mut self, window_id: WindowId, rect: Rect) -> Result<()> { + debug!( + "Moving window {:?} to {:?} via Accessibility API", + window_id, rect + ); - // Try to refresh cache if window not found and cache is stale - if !self.window_cache.contains_key(&window_id) { - if let Err(e) = self.refresh_window_cache() { - warn!("Failed to refresh window cache: {}", e); + // Force refresh the cache to ensure we have current windows + if let Err(e) = self.refresh_all_windows_cache() { + warn!("Failed to refresh all windows cache: {}", e); + } + + // Try direct approach by finding the window element + if let Some(element) = self.find_window_element(window_id)? { + let result = self.set_window_rect(element, rect); + unsafe { + CFRelease(element); // Release the retained element + } + result?; + debug!("Successfully moved window {:?} to {:?}", window_id, rect); + } else { + // Try to find window by brute force matching with CGWindow data + if let Some(element) = self.find_window_by_cgwindow_id(window_id)? { + let result = self.set_window_rect(element, rect); + unsafe { + CFRelease(element); + } + result?; + debug!( + "Successfully moved window {:?} via brute force search", + window_id + ); + } else { + warn!( + "Could not find accessibility element for window {:?} even with brute force search", + window_id + ); } } - if let Some((_pid, _element)) = self.window_cache.get(&window_id) { - // Window movement implementation would require complex Core Foundation dictionary creation - // For now, just log the action - debug!( - "Window movement not yet fully implemented - requires proper CF dictionary setup" + Ok(()) + } + + fn find_window_element(&mut self, window_id: WindowId) -> Result> { + // First check the cache + if let Some((_, element)) = self.window_cache.get(&window_id) { + unsafe { + // Retain the element before returning it + CFRetain(*element); + return Ok(Some(*element)); + } + } + + // If not in cache, try to refresh and look again + self.refresh_window_cache()?; + + if let Some((_, element)) = self.window_cache.get(&window_id) { + unsafe { + // Retain the element before returning it + CFRetain(*element); + return Ok(Some(*element)); + } + } + + warn!("Window {:?} not found in accessibility cache", window_id); + Ok(None) + } + + pub fn move_all_windows( + &mut self, + layouts: &std::collections::HashMap, + windows: &[crate::Window], + ) -> Result<()> { + debug!("Moving ALL desktop windows using accessibility API"); + + // Debug: show the layouts we're supposed to apply + for (window_id, rect) in layouts { + debug!("Layout for window {:?}: {:?}", window_id, rect); + } + + // Build HashMap from WindowId to expected Rect for O(1) lookup + let window_id_to_rect: HashMap = layouts.clone(); + + // Get unique PIDs from the windows we need to tile + let mut unique_pids = std::collections::HashSet::new(); + for window in windows { + unique_pids.insert(window.owner_pid); + } + + debug!("Getting windows for PIDs: {:?}", unique_pids); + + // Process each PID to get its windows and match them with WindowIds + for pid in unique_pids { + match self.get_windows_for_app_by_pid(pid) { + Ok(app_window_elements) => { + debug!( + "Successfully got {} window elements for PID {}", + app_window_elements.len(), + pid + ); + + // For each window element from this PID, try to match with our windows + for (element_index, window_element) in app_window_elements.iter().enumerate() { + // Find the corresponding window by matching PID and index within PID + // This is more reliable than global ordering + let windows_for_pid: Vec<&crate::Window> = + windows.iter().filter(|w| w.owner_pid == pid).collect(); + + if element_index < windows_for_pid.len() { + let window_id = windows_for_pid[element_index].id; + + // Look up the rect for this specific window ID + if let Some(rect) = window_id_to_rect.get(&window_id) { + debug!( + "Moving window {:?} (PID {}, index {}) to {:?}", + window_id, pid, element_index, rect + ); + if let Err(e) = self.set_window_rect(*window_element, *rect) { + warn!("Failed to move window {:?}: {}", window_id, e); + } + } + } + } + + // Clean up retained window elements for this PID + unsafe { + for window_element in app_window_elements { + CFRelease(window_element); + } + } + } + Err(e) => { + warn!("Failed to get windows for PID {}: {}", pid, e); + } + } + } + + Ok(()) + } + + fn set_window_rect(&self, element: AXUIElementRef, rect: Rect) -> Result<()> { + unsafe { + // Create position value using AXValue + let position = CGPoint { + x: rect.x, + y: rect.y, + }; + let position_value = AXValueCreate( + K_AXVALUE_CGPOINT_TYPE, + &position as *const CGPoint as *const c_void, ); - } else { - debug!("Window {:?} not found in accessibility cache - may be a non-manageable window", window_id); + + if position_value.is_null() { + return Err(anyhow::anyhow!("Failed to create position AXValue")); + } + + let position_attr = CFString::new(K_AXPOSITION_ATTRIBUTE); + let position_result = AXUIElementSetAttributeValue( + element, + position_attr.as_concrete_TypeRef(), + position_value, + ); + + // Create size value using AXValue + let size = CGSize { + width: rect.width, + height: rect.height, + }; + let size_value = AXValueCreate( + K_AXVALUE_CGSIZE_TYPE, + &size as *const CGSize as *const c_void, + ); + + if size_value.is_null() { + CFRelease(position_value); + return Err(anyhow::anyhow!("Failed to create size AXValue")); + } + + let size_attr = CFString::new(K_AXSIZE_ATTRIBUTE); + let size_result = + AXUIElementSetAttributeValue(element, size_attr.as_concrete_TypeRef(), size_value); + + // Clean up + CFRelease(position_value); + CFRelease(size_value); + + if position_result == K_AXERROR_SUCCESS && size_result == K_AXERROR_SUCCESS { + debug!("Successfully set window rect to {:?}", rect); + Ok(()) + } else { + Err(anyhow::anyhow!( + "Failed to set window rect: position_error={}, size_error={}", + position_result, + size_result + )) + } + } + } + + #[allow(dead_code)] + fn get_all_accessible_window_elements(&mut self) -> Result> { + let mut all_windows = Vec::new(); + + // Get windows from ALL accessible applications, not just the focused one + self.enumerate_all_accessible_applications(&mut all_windows)?; + + debug!( + "Found {} accessible window elements across all applications", + all_windows.len() + ); + Ok(all_windows) + } + + #[allow(dead_code)] + fn enumerate_all_accessible_applications( + &mut self, + window_elements: &mut Vec, + ) -> Result<()> { + // Get ALL running processes and try to get windows from each + let all_pids = self.get_all_running_pids()?; + + debug!("Found {} total running processes", all_pids.len()); + + for pid in all_pids { + // Try to get windows from this PID + match self.get_windows_for_app_by_pid(pid) { + Ok(app_windows) => { + if !app_windows.is_empty() { + debug!("Found {} windows for PID {}", app_windows.len(), pid); + window_elements.extend(app_windows); + } + } + Err(e) => { + // Don't log errors for every PID as many won't have windows + debug!("No accessible windows for PID {}: {}", pid, e); + } + } + } + + debug!( + "Total accessible window elements found: {}", + window_elements.len() + ); + Ok(()) + } + + #[allow(dead_code)] + fn get_all_running_pids(&self) -> Result> { + unsafe { + let mut buffer = vec![0i32; 1024]; + let max_iterations = 5; // Prevent infinite loops + let mut iteration = 0; + + loop { + let bytes_returned = proc_listpids( + PROC_ALL_PIDS, + 0, + buffer.as_mut_ptr(), + (buffer.len() * std::mem::size_of::()) as c_int, + ); + + if bytes_returned < 0 { + return Err(anyhow::anyhow!("Failed to list processes")); + } + + let pids_returned = bytes_returned as usize / std::mem::size_of::(); + + // Check if we've reached a reasonable buffer size or hit our iteration limit + if pids_returned < buffer.len() || iteration >= max_iterations { + buffer.truncate(pids_returned); + break; + } + + // Limit buffer growth to prevent excessive memory usage + let new_size = std::cmp::min(buffer.len() * 2, 16384); // Cap at 16K PIDs + if new_size == buffer.len() { + // Can't grow anymore, use what we have + buffer.truncate(pids_returned); + break; + } + + buffer.resize(new_size, 0); + iteration += 1; + } + + let valid_pids: Vec = buffer.into_iter().filter(|&pid| pid > 0).collect(); + Ok(valid_pids) + } + } + + #[allow(dead_code)] + #[deprecated( + since = "1.0.0", + note = "Use enumerate_all_accessible_applications instead. Will be removed in v2.0" + )] + fn try_get_windows_from_other_apps( + &mut self, + _window_elements: &mut [AXUIElementRef], + ) -> Result<()> { + debug!("try_get_windows_from_other_apps is deprecated - use enumerate_all_accessible_applications"); + Ok(()) + } + + #[allow(dead_code)] + #[deprecated( + since = "1.0.0", + note = "Use get_windows_for_app_by_pid instead. Will be removed in v2.0" + )] + fn get_windows_for_app_by_name(&mut self, app_name: &str) -> Result> { + debug!( + "get_windows_for_app_by_name called for {} - use get_windows_for_app_by_pid instead", + app_name + ); + Ok(Vec::new()) + } + + fn get_windows_for_app_by_pid(&mut self, pid: i32) -> Result> { + let mut window_elements = Vec::new(); + + // Skip some obvious system processes that can't have windows + if pid <= 1 { + return Ok(window_elements); + } + + unsafe { + // Create accessibility element for the application + let app_element = AXUIElementCreateApplication(pid); + if app_element.is_null() { + return Ok(window_elements); + } + + // Get windows for this application + let windows_attr = CFString::new(K_AXWINDOWS_ATTRIBUTE); + let mut windows: CFTypeRef = std::ptr::null_mut(); + + let windows_result = AXUIElementCopyAttributeValue( + app_element, + windows_attr.as_concrete_TypeRef(), + &mut windows, + ); + + if windows_result == K_AXERROR_SUCCESS && !windows.is_null() { + let array_ref = windows as CFArrayRef; + let count = CFArrayGetCount(array_ref); + + if count > 0 { + debug!("Processing {} windows for PID {}", count, pid); + + for i in 0..count { + let window_element = CFArrayGetValueAtIndex(array_ref, i); + if !window_element.is_null() { + // Verify this is a valid, manageable window + if self.is_window_tileable(window_element) { + // Retain the window element so it doesn't get freed when we release the array + CFRetain(window_element); + window_elements.push(window_element); + } + } + } + + if !window_elements.is_empty() { + debug!( + "Found {} tileable windows for PID {}", + window_elements.len(), + pid + ); + } + } + + CFRelease(windows); + } + + CFRelease(app_element); + } + + Ok(window_elements) + } + + fn is_window_tileable(&self, window_element: AXUIElementRef) -> bool { + unsafe { + // Check if window has position and size attributes (required for tiling) + let position_attr = CFString::new(K_AXPOSITION_ATTRIBUTE); + let size_attr = CFString::new(K_AXSIZE_ATTRIBUTE); + + let mut position_value: CFTypeRef = std::ptr::null_mut(); + let mut size_value: CFTypeRef = std::ptr::null_mut(); + + let has_position = AXUIElementCopyAttributeValue( + window_element, + position_attr.as_concrete_TypeRef(), + &mut position_value, + ) == K_AXERROR_SUCCESS; + + let has_size = AXUIElementCopyAttributeValue( + window_element, + size_attr.as_concrete_TypeRef(), + &mut size_value, + ) == K_AXERROR_SUCCESS; + + if !position_value.is_null() { + CFRelease(position_value); + } + if !size_value.is_null() { + CFRelease(size_value); + } + + has_position && has_size + } + } + + #[allow(dead_code)] + fn get_windows_from_focused_app( + &mut self, + window_elements: &mut Vec, + ) -> Result<()> { + unsafe { + let focused_app_attr = CFString::new(K_AXFOCUSED_APPLICATION_ATTRIBUTE); + let mut focused_app: CFTypeRef = std::ptr::null_mut(); + + let result = AXUIElementCopyAttributeValue( + self.system_element, + focused_app_attr.as_concrete_TypeRef(), + &mut focused_app, + ); + + if result == K_AXERROR_SUCCESS && !focused_app.is_null() { + let windows_attr = CFString::new(K_AXWINDOWS_ATTRIBUTE); + let mut windows: CFTypeRef = std::ptr::null_mut(); + + let windows_result = AXUIElementCopyAttributeValue( + focused_app, + windows_attr.as_concrete_TypeRef(), + &mut windows, + ); + + if windows_result == K_AXERROR_SUCCESS && !windows.is_null() { + let array_ref = windows as CFArrayRef; + let count = CFArrayGetCount(array_ref); + + for i in 0..count { + let window_element = CFArrayGetValueAtIndex(array_ref, i); + if !window_element.is_null() { + window_elements.push(window_element); + } + } + + CFRelease(windows); + } + + CFRelease(focused_app); + } } Ok(()) @@ -190,12 +648,12 @@ impl AccessibilityManager { &mut close_button, ); - if result == kAXErrorSuccess && !close_button.is_null() { - let press_action = CFString::new(kAXPressAction); + if result == K_AXERROR_SUCCESS && !close_button.is_null() { + let press_action = CFString::new(K_AXPRESS_ACTION); let press_result = AXUIElementPerformAction(close_button, press_action.as_concrete_TypeRef()); - if press_result == kAXErrorSuccess { + if press_result == K_AXERROR_SUCCESS { debug!("Successfully closed window {:?}", window_id); } else { warn!("Failed to press close button: error {}", press_result); @@ -207,7 +665,10 @@ impl AccessibilityManager { } } } else { - debug!("Window {:?} not found in accessibility cache - may be a non-manageable window", window_id); + debug!( + "Window {:?} not found in accessibility cache - may be a non-manageable window", + window_id + ); } Ok(()) @@ -215,20 +676,368 @@ impl AccessibilityManager { pub fn refresh_window_cache(&mut self) -> Result<()> { debug!("Refreshing accessibility window cache"); - + let now = std::time::Instant::now(); // Only refresh if it's been more than 100ms since last refresh if now.duration_since(self.last_cache_update) < std::time::Duration::from_millis(100) { return Ok(()); } - // For now, we'll use a simple approach - clear the cache and let it be rebuilt on demand - // A more sophisticated approach would enumerate all applications and their windows - // via the Accessibility API and map them to CGWindow IDs + // Release all cached elements before clearing + unsafe { + for (_, element) in self.window_cache.values() { + CFRelease(*element); + } + } self.window_cache.clear(); + + // Get all windows from focused application (limited implementation) + self.enumerate_focused_app_windows()?; + self.last_cache_update = now; - - debug!("Accessibility window cache refreshed"); + debug!( + "Accessibility window cache refreshed with {} windows", + self.window_cache.len() + ); + Ok(()) + } + + pub fn refresh_all_windows_cache(&mut self) -> Result<()> { + debug!("Refreshing accessibility cache for ALL applications"); + + // Release all cached elements before clearing + unsafe { + for (_, element) in self.window_cache.values() { + CFRelease(*element); + } + } + self.window_cache.clear(); + + // Get all windows from ALL accessible applications + if let Err(e) = self.enumerate_all_app_windows() { + warn!("Failed to enumerate all app windows: {}", e); + // Fall back to focused app only + self.enumerate_focused_app_windows()?; + } + + self.last_cache_update = std::time::Instant::now(); + debug!( + "All windows accessibility cache refreshed with {} windows", + self.window_cache.len() + ); + Ok(()) + } + + fn find_window_by_cgwindow_id( + &mut self, + window_id: WindowId, + ) -> Result> { + debug!("Searching for window {:?} by CGWindow ID", window_id); + + // Get the CGWindow info for this ID to know what we're looking for + let cgwindow_id = window_id.0; + if let Ok(Some(cgwindow_info)) = + crate::macos::cgwindow::CGWindowInfo::get_window_info_by_id(cgwindow_id) + { + debug!( + "Found CGWindow info for ID {}: {} ({}) at {:?}", + cgwindow_id, cgwindow_info.title, cgwindow_info.owner, cgwindow_info.rect + ); + + // Search through all accessibility windows to find a match + return self.find_accessibility_window_by_attributes( + cgwindow_info.owner_pid, + &cgwindow_info.rect, + &cgwindow_info.title, + ); + } + + debug!("No CGWindow info found for ID {}", cgwindow_id); + Ok(None) + } + + fn find_accessibility_window_by_attributes( + &mut self, + target_pid: i32, + target_rect: &crate::Rect, + _title: &str, + ) -> Result> { + debug!( + "Searching for accessibility window with PID {} at {:?}", + target_pid, target_rect + ); + + // Get windows for the specific PID + match self.get_windows_for_app_by_pid(target_pid) { + Ok(app_windows) => { + for window_element in &app_windows { + // Get position and size of this accessibility window + if let Ok(Some(window_rect)) = self.get_window_rect(*window_element) { + // Check if positions are close (within 5 pixels) + let dx = (window_rect.x - target_rect.x).abs(); + let dy = (window_rect.y - target_rect.y).abs(); + let dw = (window_rect.width - target_rect.width).abs(); + let dh = (window_rect.height - target_rect.height).abs(); + + if dx < 5.0 && dy < 5.0 && dw < 5.0 && dh < 5.0 { + debug!("Found matching accessibility window at {:?}", window_rect); + unsafe { + CFRetain(*window_element); + // Clean up other elements + for other_element in &app_windows { + if *other_element != *window_element { + CFRelease(*other_element); + } + } + } + return Ok(Some(*window_element)); + } + } + } + + // Clean up all elements if no match found + unsafe { + for window_element in app_windows { + CFRelease(window_element); + } + } + } + Err(e) => { + debug!("Failed to get windows for PID {}: {}", target_pid, e); + } + } + + Ok(None) + } + + fn get_window_rect(&self, element: AXUIElementRef) -> Result> { + unsafe { + let position_attr = CFString::new(K_AXPOSITION_ATTRIBUTE); + let size_attr = CFString::new(K_AXSIZE_ATTRIBUTE); + + let mut position_value: CFTypeRef = std::ptr::null_mut(); + let mut size_value: CFTypeRef = std::ptr::null_mut(); + + let pos_result = AXUIElementCopyAttributeValue( + element, + position_attr.as_concrete_TypeRef(), + &mut position_value, + ); + + let size_result = AXUIElementCopyAttributeValue( + element, + size_attr.as_concrete_TypeRef(), + &mut size_value, + ); + + if pos_result == K_AXERROR_SUCCESS && size_result == K_AXERROR_SUCCESS { + // Extract actual values from AXValue objects + let mut position = CGPoint { x: 0.0, y: 0.0 }; + let mut size = CGSize { + width: 0.0, + height: 0.0, + }; + + // Extract position from AXValue + if !AXValueGetValue( + position_value, + K_AXVALUE_CGPOINT_TYPE, + &mut position as *mut CGPoint as *mut c_void, + ) { + CFRelease(position_value); + CFRelease(size_value); + return Ok(None); + } + + // Extract size from AXValue + if !AXValueGetValue( + size_value, + K_AXVALUE_CGSIZE_TYPE, + &mut size as *mut CGSize as *mut c_void, + ) { + CFRelease(position_value); + CFRelease(size_value); + return Ok(None); + } + + if !position_value.is_null() { + CFRelease(position_value); + } + if !size_value.is_null() { + CFRelease(size_value); + } + + return Ok(Some(crate::Rect::new( + position.x, + position.y, + size.width, + size.height, + ))); + } + + if !position_value.is_null() { + CFRelease(position_value); + } + if !size_value.is_null() { + CFRelease(size_value); + } + } + Ok(None) + } + + fn enumerate_all_app_windows(&mut self) -> Result<()> { + debug!("Enumerating windows from ALL applications"); + + // Get all running processes + let all_pids = self.get_all_running_pids()?; + debug!("Found {} total running processes", all_pids.len()); + + let mut total_windows = 0; + for pid in all_pids { + match self.get_windows_for_app_by_pid(pid) { + Ok(app_windows) => { + if !app_windows.is_empty() { + debug!("Found {} windows for PID {}", app_windows.len(), pid); + total_windows += app_windows.len(); + + // Add windows to cache with generated IDs + for (index, window_element) in app_windows.into_iter().enumerate() { + let window_id = + self.generate_window_id(window_element, pid, Some(index)); + unsafe { + CFRetain(window_element); + } + self.insert_window_with_collision_check( + window_id, + pid, + window_element, + index, + ); + } + } + } + Err(_) => { + // Ignore errors for processes that don't have accessible windows + } + } + } + + debug!("Total accessible windows cached: {}", total_windows); + Ok(()) + } + + fn enumerate_focused_app_windows(&mut self) -> Result<()> { + unsafe { + // Get windows from the focused application only + // This is a limited implementation - a full implementation would enumerate all apps + // via NSWorkspace.runningApplications or similar APIs + + // Get focused application windows + let focused_app_attr = CFString::new(K_AXFOCUSED_APPLICATION_ATTRIBUTE); + let mut focused_app: CFTypeRef = std::ptr::null_mut(); + + let result = AXUIElementCopyAttributeValue( + self.system_element, + focused_app_attr.as_concrete_TypeRef(), + &mut focused_app, + ); + + if result == K_AXERROR_SUCCESS && !focused_app.is_null() { + let mut pid: i32 = 0; + AXUIElementGetPid(focused_app, &mut pid); + + // Get all windows for this application + let windows_attr = CFString::new(K_AXWINDOWS_ATTRIBUTE); + let mut windows: CFTypeRef = std::ptr::null_mut(); + + let windows_result = AXUIElementCopyAttributeValue( + focused_app, + windows_attr.as_concrete_TypeRef(), + &mut windows, + ); + + if windows_result == K_AXERROR_SUCCESS && !windows.is_null() { + self.process_windows_array(windows, pid)?; + CFRelease(windows); + } + + CFRelease(focused_app); + } + } + + Ok(()) + } + + fn generate_window_id( + &self, + element: AXUIElementRef, + pid: i32, + index: Option, + ) -> WindowId { + let ptr_val = element as usize; + let hash1 = ptr_val.wrapping_mul(0x9e3779b9); + let mut hash2 = hash1.wrapping_add(pid as usize); + + if let Some(idx) = index { + hash2 = hash2.wrapping_add(idx); + } + + let hash3 = hash2.wrapping_mul(0x85ebca6b); + let final_hash = (hash3 >> 16) ^ (hash3 & 0xFFFF); + WindowId(((pid as u64) << 16 | (final_hash as u64 & 0xFFFF)) as u32) + } + + fn insert_window_with_collision_check( + &mut self, + window_id: WindowId, + pid: i32, + element: AXUIElementRef, + index: usize, + ) { + if self.window_cache.contains_key(&window_id) { + warn!( + "WindowId collision detected for {:?} (PID: {}, index: {})", + window_id, pid, index + ); + // Generate unique fallback ID using secure random component + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + let fallback_id = WindowId( + ((timestamp & 0xFFFF) << 16 | (pid as u64 & 0xFF) << 8 | (index as u64 & 0xFF)) + as u32, + ); + self.window_cache.insert(fallback_id, (pid, element)); + debug!("Using fallback WindowId {:?} for collision", fallback_id); + } else { + self.window_cache.insert(window_id, (pid, element)); + } + } + + fn process_windows_array(&mut self, windows_array: CFTypeRef, pid: i32) -> Result<()> { + unsafe { + let array_ref = windows_array as CFArrayRef; + let count = CFArrayGetCount(array_ref); + + for i in 0..count { + let window_element = CFArrayGetValueAtIndex(array_ref, i); + if !window_element.is_null() { + // Retain the element before caching it + CFRetain(window_element); + + // Generate window ID with collision detection + let window_id = self.generate_window_id(window_element, pid, Some(i as usize)); + self.insert_window_with_collision_check( + window_id, + pid, + window_element, + i as usize, + ); + } + } + } + Ok(()) } } @@ -236,6 +1045,10 @@ impl AccessibilityManager { impl Drop for AccessibilityManager { fn drop(&mut self) { unsafe { + // Release all cached window elements + for (_, element) in self.window_cache.values() { + CFRelease(*element); + } CFRelease(self.system_element); } } diff --git a/src/macos/cgwindow.rs b/src/macos/cgwindow.rs index 93fefb0..50bbe9a 100644 --- a/src/macos/cgwindow.rs +++ b/src/macos/cgwindow.rs @@ -1,8 +1,8 @@ use crate::{Rect, Result, Window, WindowId}; use log::{debug, warn}; use std::collections::HashMap; -use std::ffi::{CStr, CString}; -use std::os::raw::{c_char, c_double, c_int, c_uint, c_void}; +use std::ffi::CString; +use std::os::raw::{c_char, c_int, c_void}; use std::ptr; // Core Foundation types @@ -20,22 +20,21 @@ const K_CG_WINDOW_LIST_EXCLUDE_DESKTOP_ELEMENTS: u32 = 1 << 4; // External C functions from Core Graphics and Core Foundation extern "C" { fn CGWindowListCopyWindowInfo(option: u32, relative_to_window: u32) -> CFArrayRef; - + // Core Foundation Array functions fn CFArrayGetCount(array: CFArrayRef) -> CFIndex; fn CFArrayGetValueAtIndex(array: CFArrayRef, idx: CFIndex) -> CFTypeRef; fn CFRelease(cf: CFTypeRef); - + // Core Foundation Dictionary functions fn CFDictionaryGetValue(dict: CFDictionaryRef, key: CFStringRef) -> CFTypeRef; - + // Core Foundation String functions fn CFStringCreateWithCString( allocator: *const c_void, cstr: *const c_char, encoding: u32, ) -> CFStringRef; - fn CFStringGetCStringPtr(string: CFStringRef, encoding: u32) -> *const c_char; fn CFStringGetLength(string: CFStringRef) -> CFIndex; fn CFStringGetCString( string: CFStringRef, @@ -43,13 +42,9 @@ extern "C" { buffer_size: CFIndex, encoding: u32, ) -> bool; - + // Core Foundation Number functions - fn CFNumberGetValue( - number: CFNumberRef, - number_type: c_int, - value_ptr: *mut c_void, - ) -> bool; + fn CFNumberGetValue(number: CFNumberRef, number_type: c_int, value_ptr: *mut c_void) -> bool; } // Core Foundation String encoding @@ -57,7 +52,6 @@ const K_CF_STRING_ENCODING_UTF8: u32 = 0x08000100; // Core Foundation Number types const K_CF_NUMBER_DOUBLE_TYPE: c_int = 13; -const K_CF_NUMBER_LONG_LONG_TYPE: c_int = 11; pub struct CGWindowInfo; @@ -66,20 +60,20 @@ impl CGWindowInfo { debug!("Enumerating windows via CGWindowListCopyWindowInfo"); let mut windows = Vec::new(); - + unsafe { let window_list_info = CGWindowListCopyWindowInfo( K_CG_WINDOW_LIST_OPTION_ON_SCREEN_ONLY | K_CG_WINDOW_LIST_EXCLUDE_DESKTOP_ELEMENTS, 0, // relative_to_window (0 means all windows) ); - + if window_list_info.is_null() { warn!("Failed to get window list from CGWindowListCopyWindowInfo"); return Ok(windows); } - + let count = CFArrayGetCount(window_list_info); - + for i in 0..count { let window_dict = CFArrayGetValueAtIndex(window_list_info, i) as CFDictionaryRef; if !window_dict.is_null() { @@ -88,100 +82,138 @@ impl CGWindowInfo { } } } - + CFRelease(window_list_info); } - + debug!("Found {} windows", windows.len()); Ok(windows) } - + unsafe fn parse_window_dict(dict: CFDictionaryRef) -> Option { // Extract window ID let window_id = Self::get_number_from_dict(dict, "kCGWindowNumber")? as u32; - + // Extract window title (optional) let title = Self::get_string_from_dict(dict, "kCGWindowName") .unwrap_or_else(|| "Untitled".to_string()); - + // Extract owner name let owner = Self::get_string_from_dict(dict, "kCGWindowOwnerName") .unwrap_or_else(|| "Unknown".to_string()); - + + // Extract owner PID + let owner_pid = + Self::get_number_from_dict(dict, "kCGWindowOwnerPID").unwrap_or(-1.0) as i32; + // Extract window bounds let bounds_dict = Self::get_dict_from_dict(dict, "kCGWindowBounds")?; let rect = Self::parse_bounds_dict(bounds_dict)?; - + // Extract layer (to determine if window should be managed) let layer = Self::get_number_from_dict(dict, "kCGWindowLayer").unwrap_or(0.0) as i64; - + // Extract on-screen status - let is_on_screen = Self::get_number_from_dict(dict, "kCGWindowIsOnscreen") - .unwrap_or(0.0) != 0.0; - + let is_on_screen = + Self::get_number_from_dict(dict, "kCGWindowIsOnscreen").unwrap_or(0.0) != 0.0; + // Extract alpha (transparency) let alpha = Self::get_number_from_dict(dict, "kCGWindowAlpha").unwrap_or(1.0); - + + // Extract workspace ID (macOS Space) + let workspace_id = + Self::get_number_from_dict(dict, "kCGWindowWorkspace").unwrap_or(1.0) as u32; + // Filter out desktop elements, dock, menu bar, etc. // Layer 0 is normal application windows if layer != 0 || !is_on_screen || alpha < 0.1 { + debug!( + "Filtering out window {} ({}): layer={}, on_screen={}, alpha={}", + title, owner, layer, is_on_screen, alpha + ); return None; } - + // Skip very small windows (likely system elements) if rect.width < 50.0 || rect.height < 50.0 { + debug!( + "Filtering out small window {} ({}): {}x{}", + title, owner, rect.width, rect.height + ); return None; } - // Filter out known system applications and problematic windows - let system_apps = [ - "Dock", "SystemUIServer", "Control Center", "NotificationCenter", - "WindowServer", "loginwindow", "Spotlight", "CoreServicesUIAgent" + // Filter out known system applications that should never be tiled + let never_tile_apps = [ + "Dock", + "SystemUIServer", + "Control Center", + "NotificationCenter", + "WindowServer", + "loginwindow", + "Spotlight", + "CoreServicesUIAgent", + "Menubar", + "Menu Bar", + "SystemPreferences", ]; - - if system_apps.contains(&owner.as_str()) { + + if never_tile_apps.contains(&owner.as_str()) { return None; } - // Skip untitled windows from certain apps that are likely dialogs or panels - if title == "Untitled" && (owner.contains("Accessibility") || owner.contains("System")) { + // Skip very obvious system panels, but allow most application windows + if title.is_empty() && (owner.contains("System") || owner.len() < 3) { + debug!("Filtering out system window {} ({})", title, owner); return None; } - + + debug!( + "Successfully parsed window: {} ({}) on workspace {}", + title, owner, workspace_id + ); + + debug!( + "Creating window with CGWindow ID {}: {} ({})", + window_id, title, owner + ); + Some(Window { id: WindowId(window_id), title, owner, + owner_pid, rect, is_minimized: false, // We'll need to check this separately is_focused: false, // We'll need to check this separately - workspace_id: 1, // Default workspace for now + workspace_id, // Now properly detected from macOS }) } - + unsafe fn get_string_from_dict(dict: CFDictionaryRef, key: &str) -> Option { let key_cstr = CString::new(key).ok()?; - let cf_key = CFStringCreateWithCString(ptr::null(), key_cstr.as_ptr(), K_CF_STRING_ENCODING_UTF8); + let cf_key = + CFStringCreateWithCString(ptr::null(), key_cstr.as_ptr(), K_CF_STRING_ENCODING_UTF8); if cf_key.is_null() { return None; } - + let value = CFDictionaryGetValue(dict, cf_key); CFRelease(cf_key); - + if value.is_null() { return None; } - + let cf_string = value as CFStringRef; let length = CFStringGetLength(cf_string); - + if length == 0 { return Some(String::new()); } - + let mut buffer = vec![0u8; (length as usize) * 4 + 1]; // UTF-8 can be up to 4 bytes per char - + if CFStringGetCString( cf_string, buffer.as_mut_ptr() as *mut c_char, @@ -197,24 +229,25 @@ impl CGWindowInfo { None } } - + unsafe fn get_number_from_dict(dict: CFDictionaryRef, key: &str) -> Option { let key_cstr = CString::new(key).ok()?; - let cf_key = CFStringCreateWithCString(ptr::null(), key_cstr.as_ptr(), K_CF_STRING_ENCODING_UTF8); + let cf_key = + CFStringCreateWithCString(ptr::null(), key_cstr.as_ptr(), K_CF_STRING_ENCODING_UTF8); if cf_key.is_null() { return None; } - + let value = CFDictionaryGetValue(dict, cf_key); CFRelease(cf_key); - + if value.is_null() { return None; } - + let cf_number = value as CFNumberRef; let mut result: f64 = 0.0; - + if CFNumberGetValue( cf_number, K_CF_NUMBER_DOUBLE_TYPE, @@ -225,30 +258,31 @@ impl CGWindowInfo { None } } - + unsafe fn get_dict_from_dict(dict: CFDictionaryRef, key: &str) -> Option { let key_cstr = CString::new(key).ok()?; - let cf_key = CFStringCreateWithCString(ptr::null(), key_cstr.as_ptr(), K_CF_STRING_ENCODING_UTF8); + let cf_key = + CFStringCreateWithCString(ptr::null(), key_cstr.as_ptr(), K_CF_STRING_ENCODING_UTF8); if cf_key.is_null() { return None; } - + let value = CFDictionaryGetValue(dict, cf_key); CFRelease(cf_key); - + if value.is_null() { None } else { Some(value as CFDictionaryRef) } } - + unsafe fn parse_bounds_dict(bounds_dict: CFDictionaryRef) -> Option { let x = Self::get_number_from_dict(bounds_dict, "X")?; let y = Self::get_number_from_dict(bounds_dict, "Y")?; let width = Self::get_number_from_dict(bounds_dict, "Width")?; let height = Self::get_number_from_dict(bounds_dict, "Height")?; - + Some(Rect::new(x, y, width, height)) } @@ -269,7 +303,7 @@ impl CGWindowInfo { // For now, use the window enumeration approach // In a full implementation, we'd use AXUIElementCopyAttributeValue with kAXFocusedWindowAttribute let windows = Self::get_all_windows()?; - + // Since we can't easily determine focus from CGWindowListCopyWindowInfo alone, // we return the first window for now. A complete implementation would need // Accessibility API calls to determine the truly focused window. @@ -284,6 +318,12 @@ pub struct WindowCache { cache_duration: std::time::Duration, } +impl Default for WindowCache { + fn default() -> Self { + Self::new() + } +} + impl WindowCache { pub fn new() -> Self { Self { diff --git a/src/macos/mod.rs b/src/macos/mod.rs index ee59eca..78a8ec9 100644 --- a/src/macos/mod.rs +++ b/src/macos/mod.rs @@ -1,5 +1,6 @@ pub mod accessibility; pub mod cgwindow; +pub mod window_notifications; pub mod window_system; pub use window_system::MacOSWindowSystem; diff --git a/src/macos/window_notifications.rs b/src/macos/window_notifications.rs new file mode 100644 index 0000000..bb44db3 --- /dev/null +++ b/src/macos/window_notifications.rs @@ -0,0 +1,299 @@ +use crate::{Rect, WindowId}; +use cocoa::base::{id, nil}; +use cocoa::foundation::NSString; +use log::{debug, info, warn}; +use objc::runtime::{Class, Object, Sel}; +use objc::{class, msg_send, sel, sel_impl}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tokio::sync::mpsc; + +#[derive(Debug)] +pub enum WindowDragEvent { + DragStarted { + window_id: WindowId, + initial_rect: Rect, + owner_pid: i32, + }, + DragEnded { + window_id: WindowId, + final_rect: Rect, + owner_pid: i32, + }, +} + +pub struct WindowDragNotificationObserver { + event_sender: mpsc::Sender, + observer: Option, + dragging_windows: Arc>>, +} + +impl WindowDragNotificationObserver { + pub fn new(event_sender: mpsc::Sender) -> Self { + Self { + event_sender, + observer: None, + dragging_windows: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn start_observing(&mut self) -> Result<(), Box> { + unsafe { + let notification_center: id = { + let ns_notification_center_class = Class::get("NSNotificationCenter").unwrap(); + msg_send![ns_notification_center_class, defaultCenter] + }; + + // Create observer for window move notifications + let observer_class = self.create_observer_class()?; + let observer: id = msg_send![observer_class, new]; + + // Store reference to self in the observer for callbacks + let dragging_windows = Arc::clone(&self.dragging_windows); + let event_sender = self.event_sender.clone(); + + // Set up the observer with our callback data + (*observer).set_ivar( + "dragging_windows", + Box::into_raw(Box::new(dragging_windows)) as *const _ as *const std::ffi::c_void, + ); + (*observer).set_ivar( + "event_sender", + Box::into_raw(Box::new(event_sender)) as *const _ as *const std::ffi::c_void, + ); + + // Register for NSWindowWillMoveNotification + let will_move_name = NSString::alloc(nil).init_str("NSWindowWillMoveNotification"); + let will_move_selector = sel!(windowWillMove:); + let _: () = msg_send![notification_center, + addObserver: observer + selector: will_move_selector + name: will_move_name + object: nil + ]; + + // Register for NSWindowDidMoveNotification + let did_move_name = NSString::alloc(nil).init_str("NSWindowDidMoveNotification"); + let did_move_selector = sel!(windowDidMove:); + let _: () = msg_send![notification_center, + addObserver: observer + selector: did_move_selector + name: did_move_name + object: nil + ]; + + self.observer = Some(observer); + info!("Window drag notification observer started successfully"); + Ok(()) + } + } + + pub fn stop_observing(&mut self) { + if let Some(observer) = self.observer.take() { + unsafe { + let notification_center: id = { + let ns_notification_center_class = Class::get("NSNotificationCenter").unwrap(); + msg_send![ns_notification_center_class, defaultCenter] + }; + let _: () = msg_send![notification_center, removeObserver: observer]; + + // Clean up the observer object + let _: () = msg_send![observer, release]; + } + } + } + + unsafe fn create_observer_class(&self) -> Result<*const Class, Box> { + let superclass = class!(NSObject); + let mut decl = objc::declare::ClassDecl::new("WindowDragObserver", superclass) + .ok_or("Failed to create class declaration")?; + + // Add instance variables to store our callback data + decl.add_ivar::<*const std::ffi::c_void>("dragging_windows"); + decl.add_ivar::<*const std::ffi::c_void>("event_sender"); + + // Add windowWillMove: method + decl.add_method( + sel!(windowWillMove:), + window_will_move_callback as extern "C" fn(&mut Object, Sel, id), + ); + + // Add windowDidMove: method + decl.add_method( + sel!(windowDidMove:), + window_did_move_callback as extern "C" fn(&mut Object, Sel, id), + ); + + Ok(decl.register()) + } +} + +impl Drop for WindowDragNotificationObserver { + fn drop(&mut self) { + self.stop_observing(); + } +} + +extern "C" fn window_will_move_callback(observer: &mut Object, _cmd: Sel, notification: id) { + unsafe { + debug!("NSWindowWillMoveNotification received"); + + let window: id = msg_send![notification, object]; + if window == nil { + return; + } + + // Get window ID, initial rect, and owner PID + if let (Some(window_id), Some(rect), Some(owner_pid)) = ( + get_window_id(window), + get_window_rect(window), + get_window_owner_pid(window), + ) { + debug!( + "Window drag started: {:?} at {:?} (PID: {})", + window_id, rect, owner_pid + ); + + // Get our callback data from the observer + if let (Some(dragging_windows), Some(event_sender)) = + (get_dragging_windows(observer), get_event_sender(observer)) + { + // Store initial position + dragging_windows.lock().unwrap().insert(window_id, rect); + + // Send drag started event + let event = WindowDragEvent::DragStarted { + window_id, + initial_rect: rect, + owner_pid, + }; + + if let Err(e) = event_sender.try_send(event) { + warn!("Failed to send drag started event: {}", e); + } + } + } + } +} + +extern "C" fn window_did_move_callback(observer: &mut Object, _cmd: Sel, notification: id) { + unsafe { + debug!("NSWindowDidMoveNotification received"); + + let window: id = msg_send![notification, object]; + if window == nil { + return; + } + + // Get window ID, final rect, and owner PID + if let (Some(window_id), Some(final_rect), Some(owner_pid)) = ( + get_window_id(window), + get_window_rect(window), + get_window_owner_pid(window), + ) { + debug!( + "Window drag ended: {:?} at {:?} (PID: {})", + window_id, final_rect, owner_pid + ); + + // Get our callback data from the observer + if let (Some(dragging_windows), Some(event_sender)) = + (get_dragging_windows(observer), get_event_sender(observer)) + { + // Check if this window was being dragged + if dragging_windows + .lock() + .unwrap() + .remove(&window_id) + .is_some() + { + // Send drag ended event + let event = WindowDragEvent::DragEnded { + window_id, + final_rect, + owner_pid, + }; + + if let Err(e) = event_sender.try_send(event) { + warn!("Failed to send drag ended event: {}", e); + } + } + } + } + } +} + +unsafe fn get_window_id(window: id) -> Option { + // Get window number (NSWindow windowNumber) + let window_number: i32 = msg_send![window, windowNumber]; + if window_number > 0 { + Some(WindowId(window_number as u32)) + } else { + None + } +} + +unsafe fn get_window_rect(window: id) -> Option { + // Get window frame + let frame: cocoa::foundation::NSRect = msg_send![window, frame]; + + // Convert from Cocoa coordinates (origin at bottom-left) to our coordinates (origin at top-left) + let screen_height = { + let main_screen: id = { + let ns_screen_class = Class::get("NSScreen").unwrap(); + msg_send![ns_screen_class, mainScreen] + }; + let screen_frame: cocoa::foundation::NSRect = msg_send![main_screen, frame]; + screen_frame.size.height + }; + + let y_flipped = screen_height - frame.origin.y - frame.size.height; + + Some(Rect::new( + frame.origin.x, + y_flipped, + frame.size.width, + frame.size.height, + )) +} + +unsafe fn get_window_owner_pid(window: id) -> Option { + let window_number: i32 = msg_send![window, windowNumber]; + if window_number <= 0 { + return None; + } + + // Use NSRunningApplication to get the PID from the window's owning app + let app: id = msg_send![window, app]; + if app != nil { + let running_app: id = msg_send![app, runningApplication]; + if running_app != nil { + let pid: i32 = msg_send![running_app, processIdentifier]; + return Some(pid); + } + } + + None +} + +unsafe fn get_dragging_windows(observer: &Object) -> Option>>> { + let ptr: *const std::ffi::c_void = *observer.get_ivar("dragging_windows"); + if ptr.is_null() { + return None; + } + let boxed = Box::from_raw(ptr as *mut Arc>>); + let result = Some((*boxed).clone()); + let _ = Box::into_raw(boxed); // Don't drop it + result +} + +unsafe fn get_event_sender(observer: &Object) -> Option> { + let ptr: *const std::ffi::c_void = *observer.get_ivar("event_sender"); + if ptr.is_null() { + return None; + } + let boxed = Box::from_raw(ptr as *mut mpsc::Sender); + let result = Some((*boxed).clone()); + let _ = Box::into_raw(boxed); // Don't drop it + result +} diff --git a/src/macos/window_system.rs b/src/macos/window_system.rs index 18e349b..b17b212 100644 --- a/src/macos/window_system.rs +++ b/src/macos/window_system.rs @@ -8,6 +8,12 @@ use std::collections::HashMap; use tokio::sync::mpsc; use tokio::time::{interval, Duration}; +// macOS workspace detection +extern "C" { + fn CGSGetActiveSpace(connection: u32) -> u32; + fn CGSMainConnectionID() -> u32; +} + #[derive(Debug, Clone)] pub struct Display { pub id: u32, @@ -73,8 +79,7 @@ impl MacOSWindowSystem { info!("Found {} display(s)", display_count); - for i in 0..display_count as usize { - let display_id = display_list[i]; + for (i, &display_id) in display_list.iter().enumerate().take(display_count as usize) { let bounds = CGDisplayBounds(display_id); let is_main = display_id == main_display_id; @@ -116,7 +121,12 @@ impl MacOSWindowSystem { let sender = self.event_sender.clone(); tokio::spawn(async move { - let mut interval = interval(Duration::from_millis(500)); + // Window monitoring at 200ms provides responsive detection of window changes + // TODO: Make this configurable via skew.toml with key 'window_monitor_interval_ms' + // Recommended range: 100-500ms (lower = more responsive, higher = less CPU usage) + // Note: This interval should be configurable in production as it can be + // performance-intensive with CGWindowListCopyWindowInfo calls + let mut interval = interval(Duration::from_millis(200)); let mut last_windows = Vec::new(); loop { @@ -124,6 +134,13 @@ impl MacOSWindowSystem { match CGWindowInfo::get_all_windows() { Ok(current_windows) => { + debug!("Window scan found {} windows", current_windows.len()); + for window in ¤t_windows { + debug!( + "Window: {} ({}), workspace: {}, rect: {:?}", + window.title, window.owner, window.workspace_id, window.rect + ); + } Self::detect_window_changes(&sender, &last_windows, ¤t_windows).await; last_windows = current_windows; } @@ -144,6 +161,10 @@ impl MacOSWindowSystem { ) { for new_window in new_windows { if !old_windows.iter().any(|w| w.id == new_window.id) { + debug!( + "New window detected: {} ({})", + new_window.title, new_window.owner + ); let _ = sender .send(WindowEvent::WindowCreated(new_window.clone())) .await; @@ -154,10 +175,7 @@ impl MacOSWindowSystem { || old_window.rect.height != new_window.rect.height { let _ = sender - .send(WindowEvent::WindowMoved( - new_window.id, - new_window.rect.clone(), - )) + .send(WindowEvent::WindowMoved(new_window.id, new_window.rect)) .await; } } @@ -187,7 +205,7 @@ impl MacOSWindowSystem { .values() .find(|d| d.is_main) .ok_or_else(|| anyhow::anyhow!("No main display found"))?; - Ok(main_display.rect.clone()) + Ok(main_display.rect) } pub fn get_displays(&self) -> &HashMap { @@ -279,6 +297,14 @@ impl MacOSWindowSystem { self.accessibility.move_window(window_id, rect) } + pub async fn move_all_windows( + &mut self, + layouts: &std::collections::HashMap, + windows: &[crate::Window], + ) -> Result<()> { + self.accessibility.move_all_windows(layouts, windows) + } + pub async fn close_window(&mut self, window_id: WindowId) -> Result<()> { self.accessibility.close_window(window_id) } @@ -286,4 +312,27 @@ impl MacOSWindowSystem { pub async fn get_focused_window(&self) -> Result> { self.accessibility.get_focused_window() } + + pub async fn get_current_workspace(&self) -> Result { + unsafe { + let connection = CGSMainConnectionID(); + if connection == 0 { + return Err(anyhow::anyhow!("Failed to get main connection ID")); + } + + let workspace = CGSGetActiveSpace(connection); + if workspace == 0 { + // SAFETY: CGSGetActiveSpace can return 0 on failure or when the system + // is in an inconsistent state. Falling back to workspace 1 provides + // a reasonable default that allows the window manager to continue + // functioning, as workspace 1 typically represents the first/main desktop. + // This fallback prevents crashes while maintaining basic functionality. + warn!("CGSGetActiveSpace returned 0, falling back to workspace 1"); + debug!("Workspace fallback reason: CGS API returned invalid workspace ID"); + Ok(1) + } else { + Ok(workspace) + } + } + } } diff --git a/src/main.rs b/src/main.rs index be22f4e..2a5e334 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use clap::{Parser, Subcommand}; -use log::{error, info}; +use log::info; use skew::{Config, Result, WindowManager}; use std::path::PathBuf; @@ -28,7 +28,11 @@ enum Commands { #[tokio::main] async fn main() -> Result<()> { - env_logger::init(); + // Initialize logger to show all log levels with timestamps + env_logger::Builder::from_default_env() + .filter_level(log::LevelFilter::Debug) + .format_timestamp_secs() + .init(); let cli = Cli::parse(); diff --git a/src/plugins.rs b/src/plugins.rs index b60c56a..4938b86 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -31,7 +31,9 @@ pub struct PluginManager { #[cfg(feature = "scripting")] pub struct LuaPlugin { + #[allow(dead_code)] name: String, + #[allow(dead_code)] script_path: PathBuf, } diff --git a/src/snap.rs b/src/snap.rs new file mode 100644 index 0000000..54d640b --- /dev/null +++ b/src/snap.rs @@ -0,0 +1,537 @@ +use crate::{Rect, Window, WindowId}; +use log::debug; +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SnapRegion { + Center, + North, + South, + East, + West, + NorthEast, + NorthWest, + SouthEast, + SouthWest, +} + +impl SnapRegion { + pub fn name(&self) -> &'static str { + match self { + Self::Center => "Center", + Self::North => "North", + Self::South => "South", + Self::East => "East", + Self::West => "West", + Self::NorthEast => "NorthEast", + Self::NorthWest => "NorthWest", + Self::SouthEast => "SouthEast", + Self::SouthWest => "SouthWest", + } + } +} + +#[derive(Debug, Clone)] +pub struct SnapZone { + pub region: SnapRegion, + pub bounds: Rect, + pub snap_rect: Rect, +} + +#[derive(Debug, Clone)] +pub enum DragResult { + SnapToZone(Rect), + SwapWithWindow(WindowId, Rect), // WindowId to swap with, original rect of dragged window + ReturnToOriginal(Rect), + NoAction, +} + +pub struct SnapManager { + screen_rect: Rect, + snap_zones: Vec, + snap_threshold: f64, + window_drag_states: HashMap, +} + +#[derive(Debug, Clone)] +struct WindowDragState { + #[allow(dead_code)] + window_id: WindowId, + initial_rect: Rect, + is_dragging: bool, + drag_start_time: std::time::Instant, +} + +impl SnapManager { + pub fn new(screen_rect: Rect, snap_threshold: f64) -> Self { + let mut manager = Self { + screen_rect, + snap_zones: Vec::new(), + snap_threshold, + window_drag_states: HashMap::new(), + }; + manager.update_snap_zones(screen_rect); + manager + } + + pub fn update_screen_rect(&mut self, screen_rect: Rect) { + self.screen_rect = screen_rect; + self.update_snap_zones(screen_rect); + } + + fn update_snap_zones(&mut self, screen_rect: Rect) { + self.snap_zones.clear(); + + let edge_zone_width = 150.0; // Wider edge zones for easier targeting + let corner_size = 100.0; // Corner zones at edges + let margin = 8.0; // Small margin from screen edges + + debug!("Creating snap zones for screen: {:?}", screen_rect); + + // Define zone configurations based on visual representation + let zones = [ + // Center swap zone - larger area for swapping windows + ( + SnapRegion::Center, + (0.2, 0.2, 0.6, 0.6), // Zone bounds: center 60% of screen for easier targeting + (0.25, 0.25, 0.5, 0.5), // Snap rect: center quarter if no swap target + ), + // Warp zones - edge zones that "warp" windows to screen sides + ( + SnapRegion::North, + ( + corner_size, + 0.0, + screen_rect.width - 2.0 * corner_size, + edge_zone_width, + ), // Top edge zone + ( + margin, + margin, + screen_rect.width - 2.0 * margin, + screen_rect.height * 0.5 - margin, + ), // Snap to top half + ), + ( + SnapRegion::South, + ( + corner_size, + screen_rect.height - edge_zone_width, + screen_rect.width - 2.0 * corner_size, + edge_zone_width, + ), // Bottom edge zone + ( + margin, + screen_rect.height * 0.5, + screen_rect.width - 2.0 * margin, + screen_rect.height * 0.5 - margin, + ), // Snap to bottom half + ), + ( + SnapRegion::West, + ( + 0.0, + corner_size, + edge_zone_width, + screen_rect.height - 2.0 * corner_size, + ), // Left edge zone + ( + margin, + margin, + screen_rect.width * 0.5 - margin, + screen_rect.height - 2.0 * margin, + ), // Snap to left half + ), + ( + SnapRegion::East, + ( + screen_rect.width - edge_zone_width, + corner_size, + edge_zone_width, + screen_rect.height - 2.0 * corner_size, + ), // Right edge zone + ( + screen_rect.width * 0.5, + margin, + screen_rect.width * 0.5 - margin, + screen_rect.height - 2.0 * margin, + ), // Snap to right half + ), + // Corner zones for quarter-screen snapping + ( + SnapRegion::NorthWest, + (0.0, 0.0, corner_size, corner_size), + ( + margin, + margin, + screen_rect.width * 0.5 - margin, + screen_rect.height * 0.5 - margin, + ), + ), + ( + SnapRegion::NorthEast, + ( + screen_rect.width - corner_size, + 0.0, + corner_size, + corner_size, + ), + ( + screen_rect.width * 0.5, + margin, + screen_rect.width * 0.5 - margin, + screen_rect.height * 0.5 - margin, + ), + ), + ( + SnapRegion::SouthWest, + ( + 0.0, + screen_rect.height - corner_size, + corner_size, + corner_size, + ), + ( + margin, + screen_rect.height * 0.5, + screen_rect.width * 0.5 - margin, + screen_rect.height * 0.5 - margin, + ), + ), + ( + SnapRegion::SouthEast, + ( + screen_rect.width - corner_size, + screen_rect.height - corner_size, + corner_size, + corner_size, + ), + ( + screen_rect.width * 0.5, + screen_rect.height * 0.5, + screen_rect.width * 0.5 - margin, + screen_rect.height * 0.5 - margin, + ), + ), + ]; + + for (region, bounds_config, snap_config) in zones { + let bounds = self.create_absolute_zone_rect(screen_rect, bounds_config); + let snap_rect = self.create_absolute_zone_rect(screen_rect, snap_config); + + debug!("{} zone bounds: {:?}", region.name(), bounds); + + self.snap_zones.push(SnapZone { + region, + bounds, + snap_rect, + }); + } + } + + fn create_absolute_zone_rect(&self, screen_rect: Rect, config: (f64, f64, f64, f64)) -> Rect { + let (x_config, y_config, w_config, h_config) = config; + + // Handle both absolute coordinates and relative percentages + let x = if x_config <= 1.0 { + screen_rect.x + screen_rect.width * x_config + } else { + screen_rect.x + x_config + }; + + let y = if y_config <= 1.0 { + screen_rect.y + screen_rect.height * y_config + } else { + screen_rect.y + y_config + }; + + let width = if w_config <= 1.0 { + screen_rect.width * w_config + } else { + w_config + }; + + let height = if h_config <= 1.0 { + screen_rect.height * h_config + } else { + h_config + }; + + Rect::new(x, y, width, height) + } + + pub fn start_window_drag(&mut self, window_id: WindowId, current_rect: Rect) { + self.window_drag_states.insert( + window_id, + WindowDragState { + window_id, + initial_rect: current_rect, + is_dragging: true, + drag_start_time: std::time::Instant::now(), + }, + ); + } + + pub fn update_window_drag(&mut self, window_id: WindowId, _current_rect: Rect) { + if let Some(drag_state) = self.window_drag_states.get_mut(&window_id) { + drag_state.is_dragging = true; + } + } + + pub fn end_window_drag( + &mut self, + window_id: WindowId, + final_rect: Rect, + all_windows: &[&Window], + ) -> DragResult { + debug!( + "๐ŸŽฌ SnapManager::end_window_drag called for window {:?}", + window_id + ); + + if let Some(drag_state) = self.window_drag_states.remove(&window_id) { + // Check if the window was dragged for a meaningful amount of time/distance + let drag_duration = drag_state.drag_start_time.elapsed(); + let drag_distance = self.calculate_drag_distance(&drag_state.initial_rect, &final_rect); + + debug!( + "๐Ÿ• Drag ended for window {:?}: duration={}ms, distance={:.1}px, initial={:?}, final={:?}", + window_id, + drag_duration.as_millis(), + drag_distance, + drag_state.initial_rect, + final_rect + ); + + // Use configurable thresholds for better user experience + let min_time_ms = 100u128; // 100ms minimum drag time + let min_distance = self.snap_threshold; // Use snap_threshold directly for distance + + if drag_duration.as_millis() > min_time_ms && drag_distance > min_distance { + debug!("โœ… Drag qualifies for processing, checking targets..."); + + let center_x = final_rect.x + final_rect.width / 2.0; + let center_y = final_rect.y + final_rect.height / 2.0; + + // Determine which zone we're in + let current_zone = self.find_zone_at_point(center_x, center_y); + + match current_zone { + Some(SnapRegion::Center) => { + // In center swap zone - check for window to swap with + if let Some(target_window_id) = + self.find_window_under_drag(window_id, final_rect, all_windows) + { + debug!("๐Ÿ”„ Window dropped over another window in swap zone, initiating swap"); + return DragResult::SwapWithWindow(target_window_id, drag_state.initial_rect); + } else { + debug!("๐Ÿ“ In center zone but no window to swap with, returning to original"); + return DragResult::ReturnToOriginal(drag_state.initial_rect); + } + } + Some( + SnapRegion::North + | SnapRegion::South + | SnapRegion::East + | SnapRegion::West + | SnapRegion::NorthEast + | SnapRegion::NorthWest + | SnapRegion::SouthEast + | SnapRegion::SouthWest, + ) => { + // In warp/corner zone - snap to that zone regardless of other windows + if let Some(snap_rect) = self.find_snap_target(final_rect) { + debug!("๐ŸŽฏ Found warp/corner target: {:?}", snap_rect); + let dx = (snap_rect.x - final_rect.x).abs(); + let dy = (snap_rect.y - final_rect.y).abs(); + let dw = (snap_rect.width - final_rect.width).abs(); + let dh = (snap_rect.height - final_rect.height).abs(); + + if dx > 10.0 || dy > 10.0 || dw > 10.0 || dh > 10.0 { + debug!("๐Ÿ“Œ Warping to zone"); + return DragResult::SnapToZone(snap_rect); + } else { + debug!("โ†ฉ๏ธ Already close to warp target, returning to original"); + return DragResult::ReturnToOriginal(drag_state.initial_rect); + } + } else { + debug!("๐ŸŽฏโŒ No warp target found"); + return DragResult::ReturnToOriginal(drag_state.initial_rect); + } + } + None => { + // Outside all zones - return to original position + debug!("๐Ÿšซ Outside all snap zones, returning to original position"); + return DragResult::ReturnToOriginal(drag_state.initial_rect); + } + } + } else { + debug!( + "โฑ๏ธ Drag too short/small for processing (duration: {}ms < {}ms OR distance: {:.1}px < {:.1}px), returning to original", + drag_duration.as_millis(), + min_time_ms, + drag_distance, + min_distance + ); + return DragResult::ReturnToOriginal(drag_state.initial_rect); + } + } else { + debug!("โŒ No drag state found for window {:?}", window_id); + } + DragResult::NoAction + } + + fn calculate_drag_distance(&self, initial: &Rect, final_rect: &Rect) -> f64 { + let dx = final_rect.x - initial.x; + let dy = final_rect.y - initial.y; + (dx * dx + dy * dy).sqrt() + } + + pub fn find_snap_target(&self, window_rect: Rect) -> Option { + // Use the window's center point to determine which snap zone it's in + let center_x = window_rect.x + window_rect.width / 2.0; + let center_y = window_rect.y + window_rect.height / 2.0; + + // Check which zone the window center is in and return the first match + // The order matters: corners, then sides, then center + + // Check corners first (they're more specific) + for zone in &self.snap_zones { + if matches!( + zone.region, + SnapRegion::NorthWest + | SnapRegion::NorthEast + | SnapRegion::SouthWest + | SnapRegion::SouthEast + ) && self.point_in_rect(center_x, center_y, &zone.bounds) + { + debug!( + "Window center ({}, {}) in {} zone", + center_x, + center_y, + zone.region.name() + ); + return Some(zone.snap_rect); + } + } + + // Then check sides + for zone in &self.snap_zones { + if matches!( + zone.region, + SnapRegion::North | SnapRegion::South | SnapRegion::East | SnapRegion::West + ) && self.point_in_rect(center_x, center_y, &zone.bounds) + { + debug!( + "Window center ({}, {}) in {} zone", + center_x, + center_y, + zone.region.name() + ); + return Some(zone.snap_rect); + } + } + + // Finally check center + for zone in &self.snap_zones { + if zone.region == SnapRegion::Center + && self.point_in_rect(center_x, center_y, &zone.bounds) + { + debug!( + "Window center ({}, {}) in {} zone", + center_x, + center_y, + zone.region.name() + ); + return Some(zone.snap_rect); + } + } + + debug!( + "Window center ({}, {}) not in any snap zone", + center_x, center_y + ); + None + } + + fn point_in_rect(&self, x: f64, y: f64, rect: &Rect) -> bool { + x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height + } + + pub fn find_zone_at_point(&self, x: f64, y: f64) -> Option { + // Check corners first (most specific) + for zone in &self.snap_zones { + if matches!( + zone.region, + SnapRegion::NorthWest + | SnapRegion::NorthEast + | SnapRegion::SouthWest + | SnapRegion::SouthEast + ) && self.point_in_rect(x, y, &zone.bounds) + { + return Some(zone.region); + } + } + + // Then check edges + for zone in &self.snap_zones { + if matches!( + zone.region, + SnapRegion::North | SnapRegion::South | SnapRegion::East | SnapRegion::West + ) && self.point_in_rect(x, y, &zone.bounds) + { + return Some(zone.region); + } + } + + // Finally check center + for zone in &self.snap_zones { + if zone.region == SnapRegion::Center && self.point_in_rect(x, y, &zone.bounds) { + return Some(zone.region); + } + } + + None + } + + pub fn find_window_under_drag( + &self, + dragged_window_id: WindowId, + dragged_rect: Rect, + all_windows: &[&Window], + ) -> Option { + let center_x = dragged_rect.x + dragged_rect.width / 2.0; + let center_y = dragged_rect.y + dragged_rect.height / 2.0; + + for window in all_windows { + if window.id == dragged_window_id { + continue; + } + + if self.point_in_rect(center_x, center_y, &window.rect) { + debug!( + "Found window {:?} under dragged window {:?}", + window.id, dragged_window_id + ); + return Some(window.id); + } + } + + None + } + + pub fn get_snap_zones(&self) -> &[SnapZone] { + &self.snap_zones + } + + pub fn is_window_dragging(&self, window_id: WindowId) -> bool { + self.window_drag_states + .get(&window_id) + .map(|state| state.is_dragging) + .unwrap_or(false) + } + + pub fn clear_drag_state(&mut self, window_id: WindowId) { + self.window_drag_states.remove(&window_id); + } +} diff --git a/src/window_manager.rs b/src/window_manager.rs index ae6e5a0..0f878a0 100644 --- a/src/window_manager.rs +++ b/src/window_manager.rs @@ -2,10 +2,12 @@ use crate::focus::FocusManager; use crate::hotkeys::HotkeyManager; use crate::ipc::IpcServer; use crate::layout::LayoutManager; +use crate::macos::window_notifications::{WindowDragEvent, WindowDragNotificationObserver}; use crate::macos::MacOSWindowSystem; use crate::plugins::PluginManager; +use crate::snap::{DragResult, SnapManager}; use crate::{Config, Rect, Result, WindowId}; -use log::{debug, error, info}; +use log::{debug, error, info, warn}; use std::collections::HashMap; use tokio::sync::mpsc; use tokio::time::{interval, Duration}; @@ -15,6 +17,7 @@ pub struct Window { pub id: WindowId, pub title: String, pub owner: String, + pub owner_pid: i32, pub rect: Rect, pub is_minimized: bool, pub is_focused: bool, @@ -63,10 +66,25 @@ pub struct WindowManager { ipc_server: IpcServer, hotkey_manager: HotkeyManager, plugin_manager: PluginManager, + snap_manager: SnapManager, event_rx: mpsc::Receiver, command_rx: mpsc::Receiver, + #[allow(dead_code)] command_tx: mpsc::Sender, + + // Drag notification system + drag_observer: WindowDragNotificationObserver, + drag_event_rx: mpsc::Receiver, + + // Track windows being moved programmatically to avoid snap conflicts + programmatically_moving: std::collections::HashSet, + + // Track actual user drag state (via NSWindow notifications) + user_dragging_windows: std::collections::HashSet, + + // Track window previous positions for immediate drag detection + previous_window_positions: std::collections::HashMap, } impl WindowManager { @@ -81,6 +99,15 @@ impl WindowManager { let hotkey_manager = HotkeyManager::new(&config.hotkeys, command_tx.clone())?; let plugin_manager = PluginManager::new(&config.plugins)?; + // Set up drag notification system using NSWindow notifications + let (drag_event_tx, drag_event_rx) = mpsc::channel(100); + let mut drag_observer = WindowDragNotificationObserver::new(drag_event_tx); + drag_observer.start_observing().map_err(|e| anyhow::anyhow!("Failed to start drag observer: {}", e))?; + + // Initialize snap manager with screen rect + let screen_rect = macos.get_screen_rect().await?; + let snap_manager = SnapManager::new(screen_rect, 50.0); // 50px snap threshold + Ok(Self { config, windows: HashMap::new(), @@ -91,9 +118,15 @@ impl WindowManager { ipc_server, hotkey_manager, plugin_manager, + snap_manager, event_rx, command_rx, command_tx, + drag_observer, + drag_event_rx, + programmatically_moving: std::collections::HashSet::new(), + user_dragging_windows: std::collections::HashSet::new(), + previous_window_positions: std::collections::HashMap::new(), }) } @@ -105,7 +138,19 @@ impl WindowManager { self.ipc_server.start().await?; self.hotkey_manager.start().await?; - let mut refresh_timer = interval(Duration::from_millis(100)); + // Apply layout to existing windows on startup + info!("Applying initial layout to existing windows..."); + self.refresh_windows().await?; + self.apply_layout().await?; + info!("Initial layout application completed"); + + // Refresh timer runs every 1000ms to periodically sync window state, + // while window monitoring runs at 200ms for responsiveness. + // TODO: Make both intervals configurable via skew.toml: + // - 'window_refresh_interval_ms' (current: 1000ms, recommended: 500-2000ms) + // - 'window_monitor_interval_ms' (current: 200ms, recommended: 100-500ms) + // The slower refresh prevents excessive API calls while maintaining accuracy. + let mut refresh_timer = interval(Duration::from_millis(1000)); loop { tokio::select! { @@ -119,6 +164,11 @@ impl WindowManager { error!("Error handling command: {}", e); } } + Some(drag_event) = self.drag_event_rx.recv() => { + if let Err(e) = self.handle_drag_event(drag_event).await { + error!("Error handling drag event: {}", e); + } + } _ = refresh_timer.tick() => { if let Err(e) = self.refresh_windows().await { error!("Error refreshing windows: {}", e); @@ -144,8 +194,27 @@ impl WindowManager { } } WindowEvent::WindowMoved(id, new_rect) => { - if let Some(window) = self.windows.get_mut(&id) { - window.rect = new_rect; + // Handle programmatic move cleanup + if self.programmatically_moving.contains(&id) { + debug!("Ignoring programmatic move for window {:?}", id); + self.programmatically_moving.remove(&id); + if let Some(window) = self.windows.get_mut(&id) { + window.rect = new_rect; + } + // Update previous position tracking for programmatic moves + self.previous_window_positions.insert(id, new_rect); + } else if self.user_dragging_windows.contains(&id) { + // This is a user drag that NSWindow notifications already started tracking + debug!("Window {:?} moved during NSWindow drag to {:?}", id, new_rect); + if let Some(window) = self.windows.get_mut(&id) { + window.rect = new_rect; + } + // Update position tracking but don't trigger immediate positioning + self.previous_window_positions.insert(id, new_rect); + } else { + // This is a user move - process for potential snapping + debug!("Window {:?} moved to {:?}", id, new_rect); + self.handle_immediate_window_positioning(id, new_rect).await?; } } WindowEvent::WindowResized(id, new_rect) => { @@ -201,6 +270,7 @@ impl WindowManager { } Command::MoveWindow(id, rect) => { if self.windows.contains_key(&id) { + self.programmatically_moving.insert(id); self.macos.move_window(id, rect).await?; } } @@ -216,14 +286,17 @@ impl WindowManager { if let Some(focused_id) = self.get_focused_window_id() { if let Some(target_id) = self.find_window_in_direction(direction) { // For now, just swap the focused window with the target - if let (Some(focused_window), Some(target_window)) = - (self.windows.get(&focused_id), self.windows.get(&target_id)) { + if let (Some(focused_window), Some(target_window)) = + (self.windows.get(&focused_id), self.windows.get(&target_id)) + { let focused_rect = focused_window.rect; let target_rect = target_window.rect; - + + self.programmatically_moving.insert(focused_id); + self.programmatically_moving.insert(target_id); self.macos.move_window(focused_id, target_rect).await?; self.macos.move_window(target_id, focused_rect).await?; - + info!("Swapped windows in direction {:?}", direction); } } @@ -238,7 +311,10 @@ impl WindowManager { Command::ToggleLayout => { self.layout_manager.toggle_layout(); self.apply_layout().await?; - info!("Toggled layout to: {:?}", self.layout_manager.get_current_layout()); + info!( + "Toggled layout to: {:?}", + self.layout_manager.get_current_layout() + ); } Command::ToggleFloat => { if let Some(_focused_id) = self.get_focused_window_id() { @@ -251,6 +327,7 @@ impl WindowManager { if let Some(focused_id) = self.get_focused_window_id() { // Get screen rect and move window to fill it let screen_rect = self.macos.get_screen_rect().await?; + self.programmatically_moving.insert(focused_id); self.macos.move_window(focused_id, screen_rect).await?; info!("Toggled fullscreen for focused window"); } @@ -258,23 +335,27 @@ impl WindowManager { Command::SwapMain => { if let Some(focused_id) = self.get_focused_window_id() { // Find the "main" window (first in layout order) and swap with focused + let effective_workspace = self.get_effective_current_workspace(); let workspace_windows: Vec<&Window> = self .windows .values() - .filter(|w| w.workspace_id == self.current_workspace && !w.is_minimized) + .filter(|w| w.workspace_id == effective_workspace && !w.is_minimized) .collect(); - + if let Some(main_window) = workspace_windows.first() { let main_id = main_window.id; if main_id != focused_id { - if let (Some(focused_window), Some(main_window)) = - (self.windows.get(&focused_id), self.windows.get(&main_id)) { + if let (Some(focused_window), Some(main_window)) = + (self.windows.get(&focused_id), self.windows.get(&main_id)) + { let focused_rect = focused_window.rect; let main_rect = main_window.rect; - + + self.programmatically_moving.insert(focused_id); + self.programmatically_moving.insert(main_id); self.macos.move_window(focused_id, main_rect).await?; self.macos.move_window(main_id, focused_rect).await?; - + info!("Swapped focused window with main window"); } } @@ -303,14 +384,399 @@ impl WindowManager { Ok(()) } - + + async fn handle_drag_event(&mut self, event: WindowDragEvent) -> Result<()> { + match event { + WindowDragEvent::DragStarted { window_id, initial_rect, owner_pid } => { + info!("๐Ÿš€ DRAG STARTED (NSWindow): window {:?} at {:?} (PID: {})", window_id, initial_rect, owner_pid); + + // Track that this window is being dragged by the user + self.user_dragging_windows.insert(window_id); + + // Start tracking this drag in the snap manager + self.snap_manager.start_window_drag(window_id, initial_rect); + + // Store the original position for potential restoration + self.previous_window_positions.insert(window_id, initial_rect); + } + WindowDragEvent::DragEnded { window_id, final_rect, owner_pid } => { + info!("๐Ÿ›‘ DRAG ENDED (NSWindow): window {:?} at {:?} (PID: {})", window_id, final_rect, owner_pid); + + // Remove from user dragging set first + self.user_dragging_windows.remove(&window_id); + + // Check if this window is managed by us + if self.windows.contains_key(&window_id) { + // Update our internal state with final position + if let Some(window) = self.windows.get_mut(&window_id) { + window.rect = final_rect; + } + + // Get the initial rect from snap manager for drag processing + if self.snap_manager.is_window_dragging(window_id) { + // Get current windows for accurate workspace filtering + let current_windows = self.macos.get_windows().await?; + let effective_workspace = self.get_effective_current_workspace(); + let workspace_windows: Vec<&crate::Window> = current_windows + .iter() + .filter(|w| w.workspace_id == effective_workspace && !w.is_minimized) + .collect(); + + // Process the drag end with snap manager + let drag_result = self.snap_manager.end_window_drag(window_id, final_rect, &workspace_windows); + + match drag_result { + crate::snap::DragResult::SnapToZone(snap_rect) => { + info!("๐Ÿ“ Snapping dragged window {:?} to zone at {:?}", window_id, snap_rect); + self.programmatically_moving.insert(window_id); + if let Err(e) = self.macos.move_window(window_id, snap_rect).await { + warn!("โŒ Failed to snap window after drag: {}", e); + } else { + if let Some(window) = self.windows.get_mut(&window_id) { + window.rect = snap_rect; + } + self.previous_window_positions.insert(window_id, snap_rect); + } + } + crate::snap::DragResult::SwapWithWindow(target_id, original_rect) => { + info!("๐Ÿ”„ Swapping dragged window {:?} with target {:?}", window_id, target_id); + // Use the enhanced swap_windows method + if let Err(e) = self.swap_windows_with_rects(window_id, target_id, original_rect).await { + warn!("โŒ Failed to swap windows after drag: {}", e); + } + } + crate::snap::DragResult::ReturnToOriginal(original_rect) => { + info!("โ†ฉ๏ธ Returning dragged window {:?} to original position {:?}", window_id, original_rect); + self.programmatically_moving.insert(window_id); + if let Err(e) = self.macos.move_window(window_id, original_rect).await { + warn!("โŒ Failed to return window to original position: {}", e); + } else { + if let Some(window) = self.windows.get_mut(&window_id) { + window.rect = original_rect; + } + self.previous_window_positions.insert(window_id, original_rect); + } + } + crate::snap::DragResult::NoAction => { + debug!("No action needed for dragged window {:?}", window_id); + } + } + + // Clear drag state + self.snap_manager.clear_drag_state(window_id); + } + } + } + } + Ok(()) + } + + async fn handle_immediate_window_positioning(&mut self, window_id: WindowId, new_rect: Rect) -> Result<()> { + // Skip immediate positioning if this window is being dragged via NSWindow notifications + // The NSWindow drag system will handle the positioning when the drag ends + if self.user_dragging_windows.contains(&window_id) { + debug!("Skipping immediate positioning for window {:?} - NSWindow drag in progress", window_id); + // Still update our internal state + self.previous_window_positions.insert(window_id, new_rect); + if let Some(window) = self.windows.get_mut(&window_id) { + window.rect = new_rect; + } + return Ok(()); + } + + let previous_rect = self.previous_window_positions.get(&window_id).copied(); + + // Update our records first + self.previous_window_positions.insert(window_id, new_rect); + if let Some(window) = self.windows.get_mut(&window_id) { + window.rect = new_rect; + } + + if let Some(prev_rect) = previous_rect { + // Check if this is a significant move that suggests user repositioning + let dx = (new_rect.x - prev_rect.x).abs(); + let dy = (new_rect.y - prev_rect.y).abs(); + let distance = (dx * dx + dy * dy).sqrt(); + + // If window moved significantly, immediately check for snap zones + if distance > 20.0 { // Higher threshold for immediate snapping + debug!("Window {:?} moved significantly from {:?} to {:?}, checking snap zones", window_id, prev_rect, new_rect); + + // Check if the window center is in a snap zone + let center_x = new_rect.x + new_rect.width / 2.0; + let center_y = new_rect.y + new_rect.height / 2.0; + + // Check which zone the window is in + let current_zone = self.snap_manager.find_zone_at_point(center_x, center_y); + + match current_zone { + Some(crate::snap::SnapRegion::Center) => { + // Center zone: check for window swap first + let effective_workspace = self.get_effective_current_workspace(); + let workspace_windows: Vec<&Window> = self + .windows + .values() + .filter(|w| w.workspace_id == effective_workspace && !w.is_minimized) + .collect(); + + if let Some(target_window_id) = self.snap_manager.find_window_under_drag(window_id, new_rect, &workspace_windows) { + debug!("๐Ÿ”„ Window in center zone over another window, swapping positions"); + self.swap_windows(window_id, target_window_id).await?; + } else { + debug!("โ†ฉ๏ธ Window in center zone but no target, returning to original"); + self.return_window_to_original(window_id, prev_rect).await?; + } + } + Some(_) => { + // Edge or corner zone: snap to that zone + if let Some(snap_rect) = self.snap_manager.find_snap_target(new_rect) { + // Check if we need to snap (avoid redundant moves) + let snap_dx = (snap_rect.x - new_rect.x).abs(); + let snap_dy = (snap_rect.y - new_rect.y).abs(); + let snap_dw = (snap_rect.width - new_rect.width).abs(); + let snap_dh = (snap_rect.height - new_rect.height).abs(); + + if snap_dx > 10.0 || snap_dy > 10.0 || snap_dw > 10.0 || snap_dh > 10.0 { + debug!("๐Ÿ“ Snapping window {:?} to zone at {:?}", window_id, snap_rect); + + // Mark as programmatic move to avoid feedback loop + self.programmatically_moving.insert(window_id); + + // Move the window to snap position + match self.macos.move_window(window_id, snap_rect).await { + Ok(_) => { + debug!("โœ… Successfully snapped window {:?}", window_id); + // Update our internal state + if let Some(window) = self.windows.get_mut(&window_id) { + window.rect = snap_rect; + } + self.previous_window_positions.insert(window_id, snap_rect); + } + Err(e) => { + warn!("โŒ Failed to snap window {:?}: {}, returning to original", window_id, e); + self.return_window_to_original(window_id, prev_rect).await?; + } + } + } + } + } + None => { + // Outside any zone: return to original + debug!("๐Ÿšซ Window outside all zones, returning to original"); + self.return_window_to_original(window_id, prev_rect).await?; + } + } + } + } else { + // First time seeing this window + debug!("Recording initial position for window {:?}: {:?}", window_id, new_rect); + } + + Ok(()) + } + + async fn swap_windows(&mut self, window1_id: WindowId, window2_id: WindowId) -> Result<()> { + // Get the most current window positions, not cached ones + let current_windows = self.macos.get_windows().await?; + + let window1_current = current_windows.iter().find(|w| w.id == window1_id); + let window2_current = current_windows.iter().find(|w| w.id == window2_id); + + if let (Some(window1), Some(window2)) = (window1_current, window2_current) { + let window1_rect = window1.rect; + let window2_rect = window2.rect; + + debug!("๐Ÿ”„ Swapping positions of windows {:?} (at {:?}) and {:?} (at {:?})", + window1_id, window1_rect, window2_id, window2_rect); + + // Mark both as programmatic moves to avoid feedback loops + self.programmatically_moving.insert(window1_id); + self.programmatically_moving.insert(window2_id); + + // Create swap layout + let mut swap_layouts = HashMap::new(); + swap_layouts.insert(window1_id, window2_rect); + swap_layouts.insert(window2_id, window1_rect); + + // Try bulk move first (more reliable) + let both_windows = vec![window1.clone(), window2.clone()]; + match self.macos.move_all_windows(&swap_layouts, &both_windows).await { + Ok(_) => { + debug!("โœ… Successfully swapped windows using bulk move"); + // Update our internal state + if let Some(w) = self.windows.get_mut(&window1_id) { + w.rect = window2_rect; + } + if let Some(w) = self.windows.get_mut(&window2_id) { + w.rect = window1_rect; + } + self.previous_window_positions.insert(window1_id, window2_rect); + self.previous_window_positions.insert(window2_id, window1_rect); + } + Err(e) => { + warn!("Bulk swap failed, trying individual moves: {}", e); + + // Fallback to individual moves + match self.macos.move_window(window1_id, window2_rect).await { + Ok(_) => { + if let Some(w) = self.windows.get_mut(&window1_id) { + w.rect = window2_rect; + } + self.previous_window_positions.insert(window1_id, window2_rect); + } + Err(e) => warn!("Failed to move window {:?} during swap: {}", window1_id, e), + } + + match self.macos.move_window(window2_id, window1_rect).await { + Ok(_) => { + if let Some(w) = self.windows.get_mut(&window2_id) { + w.rect = window1_rect; + } + self.previous_window_positions.insert(window2_id, window1_rect); + } + Err(e) => warn!("Failed to move window {:?} during swap: {}", window2_id, e), + } + } + } + } else { + warn!("Could not find current positions for windows {:?} and {:?}", window1_id, window2_id); + } + Ok(()) + } + + async fn swap_windows_with_rects(&mut self, window1_id: WindowId, window2_id: WindowId, window1_original_rect: Rect) -> Result<()> { + // Get current window positions for the target window + let current_windows = self.macos.get_windows().await?; + let window2_current = current_windows.iter().find(|w| w.id == window2_id); + + if let Some(window2) = window2_current { + let window2_rect = window2.rect; + + debug!("๐Ÿ”„ Swapping positions: window {:?} to {:?}, window {:?} to {:?}", + window1_id, window2_rect, window2_id, window1_original_rect); + + // Mark both as programmatic moves to avoid feedback loops + self.programmatically_moving.insert(window1_id); + self.programmatically_moving.insert(window2_id); + + // Create swap layout + let mut swap_layouts = HashMap::new(); + swap_layouts.insert(window1_id, window2_rect); + swap_layouts.insert(window2_id, window1_original_rect); + + // Get the current window object for window1 + let window1_current = current_windows.iter().find(|w| w.id == window1_id); + + if let Some(window1) = window1_current { + let both_windows = vec![window1.clone(), window2.clone()]; + + // Try bulk move first (more reliable) + match self.macos.move_all_windows(&swap_layouts, &both_windows).await { + Ok(_) => { + debug!("โœ… Successfully swapped windows using bulk move"); + // Update our internal state + if let Some(w) = self.windows.get_mut(&window1_id) { + w.rect = window2_rect; + } + if let Some(w) = self.windows.get_mut(&window2_id) { + w.rect = window1_original_rect; + } + self.previous_window_positions.insert(window1_id, window2_rect); + self.previous_window_positions.insert(window2_id, window1_original_rect); + } + Err(e) => { + warn!("Bulk swap failed, trying individual moves: {}", e); + + // Fallback to individual moves + match self.macos.move_window(window1_id, window2_rect).await { + Ok(_) => { + if let Some(w) = self.windows.get_mut(&window1_id) { + w.rect = window2_rect; + } + self.previous_window_positions.insert(window1_id, window2_rect); + } + Err(e) => warn!("Failed to move window {:?} during swap: {}", window1_id, e), + } + + match self.macos.move_window(window2_id, window1_original_rect).await { + Ok(_) => { + if let Some(w) = self.windows.get_mut(&window2_id) { + w.rect = window1_original_rect; + } + self.previous_window_positions.insert(window2_id, window1_original_rect); + } + Err(e) => warn!("Failed to move window {:?} during swap: {}", window2_id, e), + } + } + } + } else { + warn!("Could not find current window {:?} for swap", window1_id); + } + } else { + warn!("Could not find target window {:?} for swap", window2_id); + } + Ok(()) + } + + async fn return_window_to_original(&mut self, window_id: WindowId, original_rect: Rect) -> Result<()> { + debug!("โ†ฉ๏ธ Returning window {:?} to original position {:?}", window_id, original_rect); + + // Mark as programmatic move + self.programmatically_moving.insert(window_id); + + // Move the window back + match self.macos.move_window(window_id, original_rect).await { + Ok(_) => { + if let Some(window) = self.windows.get_mut(&window_id) { + window.rect = original_rect; + } + self.previous_window_positions.insert(window_id, original_rect); + } + Err(e) => warn!("Failed to return window {:?} to original position: {}", window_id, e), + } + + Ok(()) + } + fn get_focused_window_id(&self) -> Option { - self.windows - .values() - .find(|w| w.is_focused) - .map(|w| w.id) + self.windows.values().find(|w| w.is_focused).map(|w| w.id) } - + + fn get_effective_current_workspace(&self) -> u32 { + // Try to get workspace from focused window for more reliable detection + if let Some(focused_window) = self.windows.values().find(|w| w.is_focused) { + debug!( + "Using focused window's workspace {} for effective workspace detection", + focused_window.workspace_id + ); + return focused_window.workspace_id; + } + + // If no focused window, use the most common workspace among visible windows + let mut workspace_counts: std::collections::HashMap = + std::collections::HashMap::new(); + for window in self.windows.values().filter(|w| !w.is_minimized) { + *workspace_counts.entry(window.workspace_id).or_insert(0) += 1; + } + + if let Some((&most_common_workspace, _)) = + workspace_counts.iter().max_by_key(|(_, &count)| count) + { + debug!( + "Using most common workspace {} for effective workspace detection", + most_common_workspace + ); + return most_common_workspace; + } + + // Final fallback to stored current_workspace + debug!( + "Falling back to stored current_workspace {} for effective workspace detection", + self.current_workspace + ); + self.current_workspace + } + fn find_window_in_direction(&self, direction: crate::hotkeys::Direction) -> Option { let focused_id = self.get_focused_window_id()?; let focused_window = self.windows.get(&focused_id)?; @@ -318,44 +784,44 @@ impl WindowManager { focused_window.rect.x + focused_window.rect.width / 2.0, focused_window.rect.y + focused_window.rect.height / 2.0, ); - + + let effective_workspace = self.get_effective_current_workspace(); let workspace_windows: Vec<&Window> = self .windows .values() .filter(|w| { - w.workspace_id == self.current_workspace && - !w.is_minimized && - w.id != focused_id + w.workspace_id == effective_workspace && !w.is_minimized && w.id != focused_id }) .collect(); - + let mut best_window: Option = None; let mut best_distance = f64::INFINITY; - + for window in workspace_windows { let window_center = ( window.rect.x + window.rect.width / 2.0, window.rect.y + window.rect.height / 2.0, ); - + let is_in_direction = match direction { crate::hotkeys::Direction::Left => window_center.0 < focused_center.0, crate::hotkeys::Direction::Right => window_center.0 > focused_center.0, crate::hotkeys::Direction::Up => window_center.1 < focused_center.1, crate::hotkeys::Direction::Down => window_center.1 > focused_center.1, }; - + if is_in_direction { - let distance = ((window_center.0 - focused_center.0).powi(2) + - (window_center.1 - focused_center.1).powi(2)).sqrt(); - + let distance = ((window_center.0 - focused_center.0).powi(2) + + (window_center.1 - focused_center.1).powi(2)) + .sqrt(); + if distance < best_distance { best_distance = distance; best_window = Some(window.id); } } } - + best_window } @@ -363,18 +829,42 @@ impl WindowManager { let current_windows = self.macos.get_windows().await?; let old_count = self.windows.len(); + // Update current workspace + match self.macos.get_current_workspace().await { + Ok(workspace) => { + if workspace != self.current_workspace { + debug!( + "Workspace changed: {} -> {}", + self.current_workspace, workspace + ); + self.current_workspace = workspace; + } + } + Err(e) => { + warn!("Failed to get current workspace: {}", e); + } + } + // Build a new window map from current windows let mut new_windows = HashMap::new(); for window in current_windows { + // Store initial positions for new windows + if !self.previous_window_positions.contains_key(&window.id) { + self.previous_window_positions + .insert(window.id, window.rect); + } new_windows.insert(window.id, window); } // Replace the old window map with the new one self.windows = new_windows; - + let new_count = self.windows.len(); if old_count != new_count { - debug!("Window count changed: {} -> {} windows", old_count, new_count); + debug!( + "Window count changed: {} -> {} windows", + old_count, new_count + ); // Trigger layout update when window count changes self.apply_layout().await?; } @@ -383,18 +873,34 @@ impl WindowManager { } async fn apply_layout(&mut self) -> Result<()> { + // Use effective workspace detection for more reliable filtering + let effective_workspace = self.get_effective_current_workspace(); + + // Get windows in the effective current workspace let workspace_windows: Vec<&Window> = self .windows .values() - .filter(|w| w.workspace_id == self.current_workspace && !w.is_minimized) + .filter(|w| w.workspace_id == effective_workspace && !w.is_minimized) .collect(); if workspace_windows.is_empty() { - debug!("No windows to layout"); + debug!("No windows to layout in workspace {}", effective_workspace); return Ok(()); } - - debug!("Applying layout to {} windows using {:?}", workspace_windows.len(), self.layout_manager.get_current_layout()); + + debug!( + "Applying layout to {} windows in workspace {} using {:?}", + workspace_windows.len(), + effective_workspace, + self.layout_manager.get_current_layout() + ); + + for window in &workspace_windows { + debug!( + " Window to layout: {} ({}) at {:?}", + window.title, window.owner, window.rect + ); + } let screen_rect = self.macos.get_screen_rect().await?; let layouts = self.layout_manager.compute_layout( @@ -403,15 +909,280 @@ impl WindowManager { &self.config.general, ); - for (window_id, rect) in layouts { - debug!("Applying layout: moving window {:?} to {:?}", window_id, rect); - self.macos.move_window(window_id, rect).await?; - // Update our internal window state - if let Some(window) = self.windows.get_mut(&window_id) { - window.rect = rect; + // Mark all windows as being moved programmatically + for window_id in layouts.keys() { + self.programmatically_moving.insert(*window_id); + } + + // Use the new move_all_windows method to handle all windows at once + let workspace_windows_vec: Vec = + workspace_windows.iter().map(|w| (*w).clone()).collect(); + match self + .macos + .move_all_windows(&layouts, &workspace_windows_vec) + .await + { + Ok(_) => { + debug!("Successfully applied layout to all windows"); + // Update our internal window state + for (window_id, rect) in layouts { + if let Some(window) = self.windows.get_mut(&window_id) { + window.rect = rect; + } + } + } + Err(e) => { + warn!( + "Failed to apply layout to all windows: {}, falling back to individual moves", + e + ); + + // Fall back to individual window moves + for (window_id, rect) in layouts { + debug!( + "Applying layout: moving window {:?} to {:?}", + window_id, rect + ); + for attempt in 0..3 { + match self.macos.move_window(window_id, rect).await { + Ok(_) => { + debug!( + "Successfully moved window {:?} on attempt {}", + window_id, + attempt + 1 + ); + break; + } + Err(e) if attempt < 2 => { + debug!( + "Failed to move window {:?} on attempt {}: {}, retrying", + window_id, + attempt + 1, + e + ); + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + Err(e) => { + warn!( + "Failed to move window {:?} after 3 attempts: {}", + window_id, e + ); + } + } + } + + // Update our internal window state + if let Some(window) = self.windows.get_mut(&window_id) { + window.rect = rect; + } + } } } Ok(()) } + + + async fn handle_drag_end( + &mut self, + window_id: WindowId, + _initial_rect: Rect, + final_rect: Rect, + ) -> Result<()> { + // Get current workspace from focused window for reliable workspace detection + let effective_workspace = self.get_effective_current_workspace(); + + // Get all windows for collision detection (filter by effective workspace) + let workspace_windows: Vec<&Window> = self + .windows + .values() + .filter(|w| w.workspace_id == effective_workspace && !w.is_minimized) + .collect(); + + info!( + "๐Ÿ” Found {} windows in workspace for collision detection", + workspace_windows.len() + ); + + // Check what should happen with this drag + let drag_result = + self.snap_manager + .end_window_drag(window_id, final_rect, &workspace_windows); + + info!("๐ŸŽฏ Drag result: {:?}", drag_result); + + match drag_result { + DragResult::SnapToZone(snap_rect) => { + // Check if window needs to be moved (avoid redundant moves) + let dx = (snap_rect.x - final_rect.x).abs(); + let dy = (snap_rect.y - final_rect.y).abs(); + let dw = (snap_rect.width - final_rect.width).abs(); + let dh = (snap_rect.height - final_rect.height).abs(); + + if dx > 5.0 || dy > 5.0 || dw > 5.0 || dh > 5.0 { + info!( + "๐Ÿ“ Snapping window {:?} to zone at {:?}", + window_id, snap_rect + ); + + // Mark as programmatic move + self.programmatically_moving.insert(window_id); + + // Move the window to snap position (no delay needed with proper notifications) + match self.macos.move_window(window_id, snap_rect).await { + Ok(_) => info!("โœ… Successfully snapped window {:?} to zone", window_id), + Err(e) => warn!("โŒ Failed to snap window {:?}: {}", window_id, e), + } + + // Update our internal state + if let Some(window) = self.windows.get_mut(&window_id) { + window.rect = snap_rect; + } + } + } + DragResult::SwapWithWindow(target_window_id, original_rect) => { + info!( + "๐Ÿ”„ Swapping window {:?} with window {:?}", + window_id, target_window_id + ); + + // Get current window positions for accuracy + let current_windows = self.macos.get_windows().await?; + let target_window_current = current_windows.iter().find(|w| w.id == target_window_id); + + if let Some(target_window) = target_window_current { + let target_rect = target_window.rect; + + // Mark both windows as programmatic moves + self.programmatically_moving.insert(window_id); + self.programmatically_moving.insert(target_window_id); + + // Create layouts for both windows in their swapped positions + let mut swap_layouts = HashMap::new(); + swap_layouts.insert(window_id, target_rect); + swap_layouts.insert(target_window_id, original_rect); + + // Get both window objects for the bulk move API + let both_windows: Vec = [window_id, target_window_id] + .iter() + .filter_map(|id| current_windows.iter().find(|w| w.id == *id).cloned()) + .collect(); + + info!("๐Ÿ”„ Executing swap: dragged window {:?} -> {:?}, target window {:?} -> {:?}", + window_id, target_rect, target_window_id, original_rect); + + // Use the bulk move API which tends to be more reliable + match self.macos.move_all_windows(&swap_layouts, &both_windows).await { + Ok(_) => { + // Update our internal state + if let Some(window) = self.windows.get_mut(&window_id) { + window.rect = target_rect; + } + if let Some(window) = self.windows.get_mut(&target_window_id) { + window.rect = original_rect; + } + + // Update position tracking + self.previous_window_positions.insert(window_id, target_rect); + self.previous_window_positions.insert(target_window_id, original_rect); + + info!( + "โœ… Successfully swapped windows {:?} and {:?}", + window_id, target_window_id + ); + } + Err(e) => { + warn!("Bulk swap failed, trying individual moves: {}", e); + + // Fallback to individual moves + match self.macos.move_window(window_id, target_rect).await { + Ok(_) => { + info!("โœ… Moved dragged window to target position"); + if let Some(window) = self.windows.get_mut(&window_id) { + window.rect = target_rect; + } + self.previous_window_positions.insert(window_id, target_rect); + } + Err(e) => warn!("โŒ Failed to move dragged window: {}", e), + } + + match self.macos.move_window(target_window_id, original_rect).await { + Ok(_) => { + info!("โœ… Moved target window to original position"); + if let Some(window) = self.windows.get_mut(&target_window_id) { + window.rect = original_rect; + } + self.previous_window_positions.insert(target_window_id, original_rect); + } + Err(e) => warn!("โŒ Failed to move target window: {}", e), + } + + info!( + "โœ… Completed swap with individual moves: {:?} and {:?}", + window_id, target_window_id + ); + } + } + } else { + warn!("โŒ Target window {:?} not found in current windows", target_window_id); + } + } + DragResult::ReturnToOriginal(original_rect) => { + info!( + "โ†ฉ๏ธ Returning window {:?} to original position {:?}", + window_id, original_rect + ); + + // Mark as programmatic move + self.programmatically_moving.insert(window_id); + + // Move the window back to its original position + match self.macos.move_window(window_id, original_rect).await { + Ok(_) => { + info!("โœ… Successfully returned window {:?} to original position", window_id); + // Update our internal state + if let Some(window) = self.windows.get_mut(&window_id) { + window.rect = original_rect; + } + self.previous_window_positions.insert(window_id, original_rect); + } + Err(e) => warn!("โŒ Failed to return window {:?} to original position: {}", window_id, e), + } + } + DragResult::NoAction => { + info!("โŒ No action needed for window {:?}", window_id); + } + } + + // Always clear the drag state when we're done + self.snap_manager.clear_drag_state(window_id); + info!("๐Ÿงน Cleared drag state for window {:?}", window_id); + + Ok(()) + } + + #[allow(dead_code)] + async fn update_layout_for_manual_move( + &mut self, + window_id: WindowId, + new_rect: Rect, + ) -> Result<()> { + // For now, we'll just apply the existing layout logic + // In a more sophisticated implementation, we might update the BSP tree + // to reflect the manual positioning + debug!( + "Window {:?} manually moved to {:?}, updating layout", + window_id, new_rect + ); + + // You could implement logic here to: + // 1. Remove the window from its current position in the BSP tree + // 2. Find where it should be placed based on its new position + // 3. Rebuild the tree structure accordingly + + // For now, just ensure the layout is consistent + self.apply_layout().await?; + + Ok(()) + } }