From 6ed535be386b9af4cd87cdf30696fd57602cb137 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Fri, 6 Nov 2026 11:32:34 -0800 Subject: [PATCH] feat(testwin): a window for macOS, on the same command line and commands An AppKit window of a regular application, so that the daemon sees it as it sees any program: the same options and standard input commands as the Win32 one (title, colour, size, minimum size, fixed, late rename, fullscreen as a borderless window over its display, report with the frame in points from the top left of the main display, close, exit). The main thread runs the event loop in short turns and takes the lines the input thread queued; the program leaves when its window is closed. A window class has no meaning on macOS, so --class is accepted and ignored. The macOS source set now builds the crate; Linux still stubs it. --- Cargo.lock | 4 + crates/komorebi-testwin/Cargo.toml | 30 ++++ crates/komorebi-testwin/src/appkit.rs | 216 ++++++++++++++++++++++++++ crates/komorebi-testwin/src/main.rs | 11 +- nix/workspace-src.nix | 1 - 5 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 crates/komorebi-testwin/src/appkit.rs 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"