diff --git a/Cargo.toml b/Cargo.toml index c539c91..724b8b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ libc = "0.2.180" log = "0.4.29" minreq = { version = "2.14.1", features = ["https"] } objc2 = "0.6.3" -objc2-app-kit = { version = "0.3.2", features = ["NSImage"] } +objc2-app-kit = { version = "0.3.2", features = ["NSImage", "NSScreen"] } objc2-application-services = { version = "0.3.2", default-features = false, features = ["HIServices", "Processes"] } objc2-core-foundation = "0.3.2" objc2-core-graphics = { version = "0.3.2", features = ["CGEvent"] } diff --git a/src/app/apps.rs b/src/app/apps.rs index 9a17681..2b1539d 100644 --- a/src/app/apps.rs +++ b/src/app/apps.rs @@ -177,6 +177,40 @@ impl App { ] } + /// Window tiling actions (12 positions) + pub fn window_apps() -> Vec { + use crate::platform::macos::window::TilePosition; + + let icons = icns_data_to_handle(ICNS_ICON.to_vec()); + + let actions: &[(&str, TilePosition)] = &[ + ("Left Half", TilePosition::LeftHalf), + ("Right Half", TilePosition::RightHalf), + ("Top Half", TilePosition::TopHalf), + ("Bottom Half", TilePosition::BottomHalf), + ("Top Left Quarter", TilePosition::TopLeft), + ("Top Right Quarter", TilePosition::TopRight), + ("Bottom Left Quarter", TilePosition::BottomLeft), + ("Bottom Right Quarter", TilePosition::BottomRight), + ("Left Third", TilePosition::LeftThird), + ("Center Third", TilePosition::CenterThird), + ("Right Third", TilePosition::RightThird), + ("Maximize", TilePosition::Maximize), + ]; + + actions + .iter() + .map(|(name, pos)| App { + ranking: 0, + open_command: AppCommand::Function(Function::TileWindow(pos.clone())), + desc: "Window Tiling".to_string(), + icons: icons.clone(), + display_name: name.to_string(), + search_name: name.to_lowercase(), + }) + .collect() + } + /// This renders the app into an iced element, allowing it to be displayed in the search results pub fn render( self, diff --git a/src/app/tile/elm.rs b/src/app/tile/elm.rs index 5bc0fa5..d9dcef4 100644 --- a/src/app/tile/elm.rs +++ b/src/app/tile/elm.rs @@ -59,6 +59,8 @@ pub fn new(hotkeys: Hotkeys, config: &Config) -> (Tile, Task) { options.extend(App::basic_apps()); info!("Loaded basic apps / default apps"); + options.extend(App::window_apps()); + info!("Loaded window tiling apps"); options.par_sort_by_key(|x| x.display_name.len()); let options = AppIndex::from_apps(options); diff --git a/src/app/tile/update.rs b/src/app/tile/update.rs index 9f96a47..c7b9f1f 100644 --- a/src/app/tile/update.rs +++ b/src/app/tile/update.rs @@ -532,6 +532,14 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { } Message::RunFunction(command) => { + if let Function::TileWindow(pos) = &command { + if let Some(pid) = tile.frontmost.as_ref().map(|a| a.processIdentifier()) { + let ok = crate::platform::macos::window::tile_focused_window(pid, pos); + if !ok && tile.config.haptic_feedback { + perform_haptic(HapticPattern::Alignment); + } + } + } command.execute(&tile.config); let page_task = match tile.page { Page::Settings => Task::done(Message::SwitchToPage(Page::Main)), @@ -615,6 +623,7 @@ pub fn handle_update(tile: &mut Tile, message: Message) -> Task { new_options.extend(tile.config.shells.iter().map(|x| x.to_app())); new_options.extend(tile.config.modes.to_apps()); new_options.extend(App::basic_apps()); + new_options.extend(App::window_apps()); new_options.par_sort_by_key(|x| x.display_name.len()); tile.options = AppIndex::from_apps(new_options); diff --git a/src/commands.rs b/src/commands.rs index fd21ebb..c4f88e5 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -28,6 +28,7 @@ pub enum Function { GoogleSearch(String), Calculate(Expr), Quit, + TileWindow(crate::platform::macos::window::TilePosition), } impl Function { @@ -122,6 +123,10 @@ impl Function { }, Function::Quit => std::process::exit(0), + + // TileWindow is intercepted in the RunFunction handler which has + // access to the frontmost PID; nothing to do here. + Function::TileWindow(_) => {} } } } diff --git a/src/platform/macos/mod.rs b/src/platform/macos/mod.rs index 4bb7684..bf0bda0 100644 --- a/src/platform/macos/mod.rs +++ b/src/platform/macos/mod.rs @@ -5,6 +5,7 @@ pub mod events; pub mod haptics; pub mod launching; pub mod urlscheme; +pub mod window; use iced::wgpu::rwh::WindowHandle; diff --git a/src/platform/macos/window.rs b/src/platform/macos/window.rs new file mode 100644 index 0000000..e1fa037 --- /dev/null +++ b/src/platform/macos/window.rs @@ -0,0 +1,353 @@ +use std::ffi::c_void; + +use libc::pid_t; +use objc2::MainThreadMarker; +use objc2_app_kit::NSScreen; +use objc2_foundation::ns_string; + +type AXUIElementRef = *mut c_void; +type AXValueRef = *mut c_void; +type CFTypeRef = *const c_void; +type AXError = i32; + +const K_AX_SUCCESS: AXError = 0; +const K_AX_VALUE_CGPOINT: u32 = 1; +const K_AX_VALUE_CGSIZE: u32 = 2; + +#[repr(C)] +#[derive(Clone, Copy)] +struct RawPoint { + x: f64, + y: f64, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct RawSize { + width: f64, + height: f64, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TilePosition { + LeftHalf, + RightHalf, + TopHalf, + BottomHalf, + TopLeft, + TopRight, + BottomLeft, + BottomRight, + LeftThird, + CenterThird, + RightThird, + Maximize, +} + +#[link(name = "ApplicationServices", kind = "framework")] +unsafe extern "C" { + fn AXUIElementCreateApplication(pid: pid_t) -> AXUIElementRef; + fn AXUIElementCopyAttributeValue( + element: AXUIElementRef, + attr: *const c_void, + out: *mut CFTypeRef, + ) -> AXError; + fn AXUIElementSetAttributeValue( + element: AXUIElementRef, + attr: *const c_void, + value: CFTypeRef, + ) -> AXError; + fn AXValueCreate(ty: u32, value: *const c_void) -> AXValueRef; + fn AXValueGetValue(v: AXValueRef, ty: u32, out: *mut c_void) -> bool; +} + +#[allow(clashing_extern_declarations)] +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFRelease(cf: CFTypeRef); +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +pub fn rect_for(pos: &TilePosition, vf: Rect) -> Rect { + let hw = vf.w / 2.0; + let hh = vf.h / 2.0; + let tw = vf.w / 3.0; + match pos { + TilePosition::LeftHalf => Rect { + x: vf.x, + y: vf.y, + w: hw, + h: vf.h, + }, + TilePosition::RightHalf => Rect { + x: vf.x + hw, + y: vf.y, + w: hw, + h: vf.h, + }, + // In Cocoa coords +y is up, so "top" half lives at higher y + TilePosition::TopHalf => Rect { + x: vf.x, + y: vf.y + hh, + w: vf.w, + h: hh, + }, + TilePosition::BottomHalf => Rect { + x: vf.x, + y: vf.y, + w: vf.w, + h: hh, + }, + TilePosition::TopLeft => Rect { + x: vf.x, + y: vf.y + hh, + w: hw, + h: hh, + }, + TilePosition::TopRight => Rect { + x: vf.x + hw, + y: vf.y + hh, + w: hw, + h: hh, + }, + TilePosition::BottomLeft => Rect { + x: vf.x, + y: vf.y, + w: hw, + h: hh, + }, + TilePosition::BottomRight => Rect { + x: vf.x + hw, + y: vf.y, + w: hw, + h: hh, + }, + TilePosition::LeftThird => Rect { + x: vf.x, + y: vf.y, + w: tw, + h: vf.h, + }, + TilePosition::CenterThird => Rect { + x: vf.x + tw, + y: vf.y, + w: tw, + h: vf.h, + }, + TilePosition::RightThird => Rect { + x: vf.x + tw * 2.0, + y: vf.y, + w: tw, + h: vf.h, + }, + TilePosition::Maximize => vf, + } +} + +/// Tile the focused window of `pid` to `pos`. Returns false on hard failure. +/// Must be called from the main thread. +pub fn tile_focused_window(pid: pid_t, pos: &TilePosition) -> bool { + // kAXFocusedWindowAttribute etc. are #define CFSTR(...) macros, not linked symbols. + // Cast &NSString to *const c_void — toll-free bridged with CFStringRef. + let attr_focused_win = ns_string!("AXFocusedWindow") as *const _ as *const c_void; + let attr_position = ns_string!("AXPosition") as *const _ as *const c_void; + let attr_size = ns_string!("AXSize") as *const _ as *const c_void; + + unsafe { + let app_elem = AXUIElementCreateApplication(pid); + if app_elem.is_null() { + return false; + } + + // Get the focused window element + let mut win_ref: CFTypeRef = std::ptr::null(); + let err = AXUIElementCopyAttributeValue(app_elem, attr_focused_win, &mut win_ref); + CFRelease(app_elem as CFTypeRef); + if err != K_AX_SUCCESS || win_ref.is_null() { + return false; + } + let win = win_ref as AXUIElementRef; + + // Read current window position (AX coords: top-left origin, y downward) + let mut pos_ref: CFTypeRef = std::ptr::null(); + if AXUIElementCopyAttributeValue(win, attr_position, &mut pos_ref) != K_AX_SUCCESS + || pos_ref.is_null() + { + CFRelease(win as CFTypeRef); + return false; + } + let mut raw_pos = RawPoint { x: 0.0, y: 0.0 }; + AXValueGetValue( + pos_ref as AXValueRef, + K_AX_VALUE_CGPOINT, + &mut raw_pos as *mut RawPoint as *mut c_void, + ); + CFRelease(pos_ref); + + // Read current window size for center calculation + let mut sz_ref: CFTypeRef = std::ptr::null(); + let mut raw_sz = RawSize { + width: 0.0, + height: 0.0, + }; + if AXUIElementCopyAttributeValue(win, attr_size, &mut sz_ref) == K_AX_SUCCESS + && !sz_ref.is_null() + { + AXValueGetValue( + sz_ref as AXValueRef, + K_AX_VALUE_CGSIZE, + &mut raw_sz as *mut RawSize as *mut c_void, + ); + CFRelease(sz_ref); + } + + // Window center in AX coords + let cx = raw_pos.x + raw_sz.width / 2.0; + let cy_ax = raw_pos.y + raw_sz.height / 2.0; + + // Find the target screen via NSScreen (main thread required) + let mtm = MainThreadMarker::new().expect("must be on main thread"); + let screens = NSScreen::screens(mtm); + let count = screens.len(); + + // Primary screen height for AX ↔ Cocoa coordinate flip + // AX y = primary_h - (cocoa_y + h); Cocoa y = primary_h - ax_y - h + // Safety: NSScreen array is not mutated during this function call + let primary_h = if count > 0 { + screens.objectAtIndex_unchecked(0).frame().size.height + } else { + 768.0 + }; + + // Convert AX window center to Cocoa coords (bottom-left origin, y upward) + let cy_cocoa = primary_h - cy_ax; + + let mut target_vf = None; + for i in 0..count { + let s = screens.objectAtIndex_unchecked(i); + let f = s.frame(); + if cx >= f.origin.x + && cx < f.origin.x + f.size.width + && cy_cocoa >= f.origin.y + && cy_cocoa < f.origin.y + f.size.height + { + target_vf = Some(s.visibleFrame()); + break; + } + } + // Fall back to primary screen + if target_vf.is_none() && count > 0 { + target_vf = Some(screens.objectAtIndex_unchecked(0).visibleFrame()); + } + + let vf_ns = match target_vf { + Some(r) => r, + None => { + CFRelease(win as CFTypeRef); + return false; + } + }; + + let vf = Rect { + x: vf_ns.origin.x, + y: vf_ns.origin.y, + w: vf_ns.size.width, + h: vf_ns.size.height, + }; + + let target = rect_for(pos, vf); + + // Flip target Cocoa rect to AX coords + let ax_y = primary_h - (target.y + target.h); + let new_pos = RawPoint { + x: target.x, + y: ax_y, + }; + let new_sz = RawSize { + width: target.w, + height: target.h, + }; + + let sz_val = AXValueCreate( + K_AX_VALUE_CGSIZE, + &new_sz as *const RawSize as *const c_void, + ); + let pos_val = AXValueCreate( + K_AX_VALUE_CGPOINT, + &new_pos as *const RawPoint as *const c_void, + ); + + // Set size → position → size (double-set defeats per-app min-size clamping) + AXUIElementSetAttributeValue(win, attr_size, sz_val as CFTypeRef); + AXUIElementSetAttributeValue(win, attr_position, pos_val as CFTypeRef); + AXUIElementSetAttributeValue(win, attr_size, sz_val as CFTypeRef); + + CFRelease(sz_val as CFTypeRef); + CFRelease(pos_val as CFTypeRef); + CFRelease(win as CFTypeRef); + + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VF: Rect = Rect { + x: 0.0, + y: 23.0, + w: 1920.0, + h: 1057.0, + }; + + #[test] + fn halves_cover_full_area() { + let l = rect_for(&TilePosition::LeftHalf, VF); + let r = rect_for(&TilePosition::RightHalf, VF); + assert!((l.w + r.w - VF.w).abs() < 0.001); + assert_eq!(l.x, VF.x); + assert!((l.x + l.w - r.x).abs() < 0.001); + + let t = rect_for(&TilePosition::TopHalf, VF); + let b = rect_for(&TilePosition::BottomHalf, VF); + assert!((t.h + b.h - VF.h).abs() < 0.001); + assert!((b.y + b.h - t.y).abs() < 0.001); + } + + #[test] + fn quarters_tile_without_overlap() { + let tl = rect_for(&TilePosition::TopLeft, VF); + let tr = rect_for(&TilePosition::TopRight, VF); + let bl = rect_for(&TilePosition::BottomLeft, VF); + let br = rect_for(&TilePosition::BottomRight, VF); + assert!((tl.w + tr.w - VF.w).abs() < 0.001); + assert!((tl.h + bl.h - VF.h).abs() < 0.001); + assert!((tl.x + tl.w - tr.x).abs() < 0.001); + assert!((bl.x + bl.w - br.x).abs() < 0.001); + // top row sits above bottom row + assert!((tl.y - (bl.y + bl.h)).abs() < 0.001); + } + + #[test] + fn thirds_split_width_into_3() { + let l = rect_for(&TilePosition::LeftThird, VF); + let c = rect_for(&TilePosition::CenterThird, VF); + let r = rect_for(&TilePosition::RightThird, VF); + assert!((l.w + c.w + r.w - VF.w).abs() < 0.001); + assert!((l.x + l.w - c.x).abs() < 0.001); + assert!((c.x + c.w - r.x).abs() < 0.001); + } + + #[test] + fn maximize_equals_visible_frame() { + assert_eq!(rect_for(&TilePosition::Maximize, VF), VF); + } +}