diff --git a/Cargo.lock b/Cargo.lock index 1741325a..10353347 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4019,6 +4019,10 @@ dependencies = [ name = "komorebi-testwin" version = "0.2.0-dev" dependencies = [ + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "serde_json_lenient", "windows 0.62.2", ] diff --git a/crates/komorebi-testwin/Cargo.toml b/crates/komorebi-testwin/Cargo.toml index c9818792..fb7443f5 100644 --- a/crates/komorebi-testwin/Cargo.toml +++ b/crates/komorebi-testwin/Cargo.toml @@ -10,3 +10,33 @@ serde_json = { workspace = true } [target.'cfg(windows)'.dependencies] windows = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = { version = "0.6", default-features = false, features = ["std"] } +objc2-app-kit = { version = "0.3", default-features = false, features = [ + "std", + "NSApplication", + "NSWindow", + "NSView", + "NSColor", + "NSResponder", + "NSScreen", + "NSGraphics", + "NSEvent", + "NSRunningApplication", + "objc2-core-foundation", + "libc", +] } +objc2-core-foundation = { version = "0.3", default-features = false, features = [ + "std", + "CFCGTypes", +] } +objc2-foundation = { version = "0.3", default-features = false, features = [ + "std", + "NSString", + "NSGeometry", + "NSArray", + "NSDate", + "NSRunLoop", + "NSEnumerator", +] } diff --git a/crates/komorebi-testwin/src/appkit.rs b/crates/komorebi-testwin/src/appkit.rs new file mode 100644 index 00000000..2a906b3d --- /dev/null +++ b/crates/komorebi-testwin/src/appkit.rs @@ -0,0 +1,216 @@ +//! The test window on macOS: an ordinary titled window of a regular application, which the daemon +//! sees as it sees any program's window. +//! +//! AppKit is for the main thread, so the main thread runs the event loop in short turns, and the +//! thread that reads standard input leaves its lines in a queue that each turn takes from. The +//! program leaves when its window is closed, as a program of one window does. Its name is the name +//! of the file, so that a copy under another name is another program for a rule that matches by it. + +use std::collections::VecDeque; +use std::io::BufRead; +use std::sync::Mutex; +use std::time::Duration; +use std::time::Instant; + +use objc2::MainThreadMarker; +use objc2::MainThreadOnly; +use objc2::rc::Retained; +use objc2_app_kit::NSApplication; +use objc2_app_kit::NSApplicationActivationPolicy; +use objc2_app_kit::NSBackingStoreType; +use objc2_app_kit::NSColor; +use objc2_app_kit::NSEventMask; +use objc2_app_kit::NSScreen; +use objc2_app_kit::NSWindow; +use objc2_app_kit::NSWindowStyleMask; +use objc2_foundation::NSDate; +use objc2_foundation::NSDefaultRunLoopMode; +use objc2_foundation::NSPoint; +use objc2_foundation::NSRect; +use objc2_foundation::NSSize; +use objc2_foundation::NSString; + +use crate::cli; +use crate::cli::Command; +use crate::cli::say; + +/// The lines of standard input, in the order they came, until the main thread takes them. +static COMMANDS: Mutex> = Mutex::new(VecDeque::new()); + +/// How long a turn of the event loop waits for an event, which is also how late a command or a +/// rename is acted on. +const TURN: f64 = 0.05; + +/// The style and the frame of the window before it went fullscreen. +struct BeforeFullscreen { + style: NSWindowStyleMask, + frame: NSRect, +} + +fn color(rgb: u32) -> Retained { + let channel = |shift: u32| f64::from((rgb >> shift) & 0xff) / 255.0; + NSColor::colorWithRed_green_blue_alpha(channel(16), channel(8), channel(0), 1.0) +} + +/// The frame as the daemon and the host see it: points, with the origin at the top left of the +/// main display, where AppKit has it at the bottom left. +fn top_left_frame(mtm: MainThreadMarker, frame: NSRect) -> serde_json::Value { + let main_height = NSScreen::screens(mtm) + .iter() + .next() + .map_or(0.0, |screen| screen.frame().size.height); + serde_json::json!({ + "left": frame.origin.x.round() as i64, + "top": (main_height - (frame.origin.y + frame.size.height)).round() as i64, + "right": frame.size.width.round() as i64, + "bottom": frame.size.height.round() as i64, + }) +} + +fn run_command( + mtm: MainThreadMarker, + window: &NSWindow, + before: &mut Option, + command: Command, +) -> bool { + match command { + Command::Title(title) => { + window.setTitle(&NSString::from_str(&title)); + say(serde_json::json!({"ok": true})); + } + Command::Color(rgb) => { + window.setBackgroundColor(Some(&color(rgb))); + say(serde_json::json!({"ok": true})); + } + Command::Resize(w, h) => { + let mut frame = window.frame(); + frame.size = NSSize::new(f64::from(w), f64::from(h)); + window.setFrame_display(frame, true); + say(serde_json::json!({"ok": true})); + } + Command::Fullscreen(on) => { + fullscreen(mtm, window, before, on); + say(serde_json::json!({"ok": true})); + } + Command::Report => say(serde_json::json!({ + "window": window.windowNumber(), + "title": window.title().to_string(), + "frame": top_left_frame(mtm, window.frame()), + })), + Command::Close => window.performClose(None), + Command::Exit => return false, + Command::Invalid(why) => say(serde_json::json!({"error": why})), + Command::Empty => {} + } + true +} + +/// A borderless window over the whole of its display, as a program's own key takes it, and back. +fn fullscreen( + mtm: MainThreadMarker, + window: &NSWindow, + before: &mut Option, + on: bool, +) { + if on && before.is_none() { + let Some(screen) = window.screen().or_else(|| NSScreen::mainScreen(mtm)) else { + return; + }; + *before = Some(BeforeFullscreen { + style: window.styleMask(), + frame: window.frame(), + }); + window.setStyleMask(NSWindowStyleMask::Borderless); + window.setFrame_display(screen.frame(), true); + } else if !on && let Some(before) = before.take() { + window.setStyleMask(before.style); + window.setFrame_display(before.frame, true); + } +} + +pub fn run(options: cli::Options) { + let mtm = MainThreadMarker::new().expect("main runs on the main thread"); + let application = NSApplication::sharedApplication(mtm); + application.setActivationPolicy(NSApplicationActivationPolicy::Regular); + + let mut style = NSWindowStyleMask::Titled + | NSWindowStyleMask::Closable + | NSWindowStyleMask::Miniaturizable + | NSWindowStyleMask::Resizable; + if options.fixed { + style.remove(NSWindowStyleMask::Resizable); + } + let size = NSSize::new(f64::from(options.size.0), f64::from(options.size.1)); + // SAFETY: the designated initialiser, on the main thread. + let window = unsafe { + NSWindow::initWithContentRect_styleMask_backing_defer( + NSWindow::alloc(mtm), + NSRect::new(NSPoint::new(200.0, 200.0), size), + style, + NSBackingStoreType::Buffered, + false, + ) + }; + // SAFETY: this program owns the window, so a close does not release it a second time. + unsafe { window.setReleasedWhenClosed(false) }; + window.setTitle(&NSString::from_str(&options.title)); + if let Some(rgb) = options.color { + window.setBackgroundColor(Some(&color(rgb))); + } + if let Some((w, h)) = options.min_size { + window.setContentMinSize(NSSize::new(f64::from(w), f64::from(h))); + } + if options.fixed { + window.setContentMinSize(size); + window.setContentMaxSize(size); + } + window.makeKeyAndOrderFront(None); + #[allow(deprecated)] + application.activateIgnoringOtherApps(true); + application.finishLaunching(); + + std::thread::spawn(|| { + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { break }; + COMMANDS.lock().unwrap().push_back(line); + } + // standard input closed: the scenario is gone, so go too + COMMANDS.lock().unwrap().push_back(String::from("exit")); + }); + + let began = Instant::now(); + let mut rename = options.rename; + let mut before_fullscreen = None; + loop { + // SAFETY: the default run loop mode, on the main thread. + let event = unsafe { + application.nextEventMatchingMask_untilDate_inMode_dequeue( + NSEventMask::Any, + Some(&NSDate::dateWithTimeIntervalSinceNow(TURN)), + NSDefaultRunLoopMode, + true, + ) + }; + if let Some(event) = event { + application.sendEvent(&event); + } + if let Some((title, after)) = &rename + && began.elapsed() >= Duration::from_secs_f64(*after) + { + window.setTitle(&NSString::from_str(title)); + rename = None; + } + loop { + let Some(line) = COMMANDS.lock().unwrap().pop_front() else { + break; + }; + if !run_command(mtm, &window, &mut before_fullscreen, Command::parse(&line)) { + return; + } + } + // closed: not on screen, and not just minimized to the Dock + if !window.isVisible() && !window.isMiniaturized() { + return; + } + } +} diff --git a/crates/komorebi-testwin/src/main.rs b/crates/komorebi-testwin/src/main.rs index 33085eee..4448e071 100644 --- a/crates/komorebi-testwin/src/main.rs +++ b/crates/komorebi-testwin/src/main.rs @@ -23,6 +23,8 @@ //! On Windows the message `WM_APP + 2` also takes the window fullscreen (`wparam` 1: a borderless //! window over its monitor, as a program's own key does) or brings it back (`wparam` 0). +#[cfg(target_os = "macos")] +mod appkit; mod cli; #[cfg(windows)] mod win32; @@ -32,8 +34,13 @@ fn main() -> ::windows::core::Result<()> { win32::run(cli::parse(std::env::args().skip(1))) } -#[cfg(not(windows))] +#[cfg(target_os = "macos")] fn main() { - eprintln!("komorebi-testwin opens a window on Windows only"); + appkit::run(cli::parse(std::env::args().skip(1))); +} + +#[cfg(not(any(windows, target_os = "macos")))] +fn main() { + eprintln!("komorebi-testwin opens a window on Windows and on macOS only"); std::process::exit(1); } diff --git a/nix/workspace-src.nix b/nix/workspace-src.nix index ab4850cf..5e6a434c 100644 --- a/nix/workspace-src.nix +++ b/nix/workspace-src.nix @@ -60,7 +60,6 @@ let windowsOnly = [ "crates/komorebi-windows" "crates/komorebi-e2e-agent" - "crates/komorebi-testwin" "komorebi-gui" "komorebic-no-console" "komorebi-shortcuts"