diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 4efb2bd455b51cc9456bebba44fb890d56828f5d..ac1dd39efe88de91643906895d7a027edb726a98 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -64,7 +64,7 @@ web-sys = { version = "0.3", features = ["console", "Document", "Element", "Html [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" objc2 = "0.5.2" -objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSCell", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel", "NSWorkspace"] } +objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSCell", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSRunningApplication", "NSEvent", "NSMenu", "NSMenuItem", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel", "NSWorkspace"] } objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray", "NSRange", "NSError", "NSFileCoordinator", "NSFilePresenter", "NSFileVersion", "NSFileManager", "NSNotification", "NSOperation", "block2"] } block2 = "0.5.1" diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index 1f8cf8366bbb30c456f084a984cd9e13e371e023..f75f7bdc838c0a0388b2f3616289e6c8e8dd14f6 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -1416,6 +1416,10 @@ impl State { /// Runs `choice` where it applies now. pub(crate) fn run(&mut self, choice: Choice) -> Result<(), Box> { + #[cfg(target_os = "macos")] + if !platform::commands_allowed(&choice) { + return Ok(()); + } if !self.status(&choice, &self.format_state()).enabled { return Ok(()); } @@ -1812,8 +1816,15 @@ impl State { return Ok(()); } }; - self.commands.push(response); - Ok(()) + #[cfg(target_os = "macos")] + { + self.apply(response) + } + #[cfg(not(target_os = "macos"))] + { + self.commands.push(response); + Ok(()) + } } } diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 1d0e17262690a12fc1147f0c43d2a1d600cdfc74..83b0ae8b2a372f083f8ea067113dcc495b382399 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -9,9 +9,10 @@ use objc2::{ sel, }; use objc2_app_kit::{ - NSAlert, NSAlertFirstButtonReturn, NSApplication, NSColor, NSColorSpace, NSDatePicker, - NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventSubtype, NSEventType, NSMenu, - NSMenuItem, NSModalResponse, NSModalResponseCancel, NSModalResponseOK, NSSavePanel, NSWindow, + NSAlert, NSAlertFirstButtonReturn, NSApplication, NSApplicationActivationPolicy, NSColor, + NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, + NSEventSubtype, NSEventType, NSMenu, NSMenuItem, NSModalResponse, NSModalResponseCancel, + NSModalResponseOK, NSSavePanel, NSWindow, }; use objc2_foundation::{ MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter, @@ -1454,6 +1455,42 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] { } } +pub(crate) fn commands_allowed(choice: &commands::Choice) -> bool { + if matches!( + choice, + commands::Choice::Command(commands::Id::Settings | commands::Id::CheckForUpdates) + ) { + return true; + } + let app = NSApplication::sharedApplication( + MainThreadMarker::new().expect("Commands run on the main thread"), + ); + WINDOW.with_borrow(|window| { + let Some(window) = window else { return false }; + unsafe { + if app.modalWindow().is_some() || window.attachedSheet().is_some() { + return false; + } + match app.keyWindow() { + Some(key) => std::ptr::eq(&*key, &**window), + // Hidden screenshot replays have no key window and cannot activate. + None => { + !window.isVisible() + && app.activationPolicy() == NSApplicationActivationPolicy::Prohibited + } + } + } + }) +} + +fn send_choice(choice: commands::Choice) { + if commands_allowed(&choice) + && let Some(proxy) = PROXY.get() + { + let _ = proxy.send_event(crate::UserEvent::Choose(choice)); + } +} + declare_class!( struct CanvasApplication; @@ -1482,14 +1519,36 @@ declare_class!( #[method(choose:)] fn choose(&self, sender: &NSMenuItem) { let tag = usize::try_from(unsafe { sender.tag() }).ok(); - if let (Some(proxy), Some(choice)) = (PROXY.get(), tag.and_then(|tag| commands::choices().nth(tag))) { - let _ = proxy.send_event(crate::UserEvent::Choose(choice)); + if let Some(choice) = tag.and_then(|tag| commands::choices().nth(tag)) { + send_choice(choice); } } + #[method(cut:)] + fn cut(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Cut)); } + + #[method(copy:)] + fn copy(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Copy)); } + + #[method(paste:)] + fn paste(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Paste)); } + + #[method(selectAll:)] + fn select_all(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::SelectAll)); } + + #[method(undo:)] + fn undo(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Undo)); } + + #[method(redo:)] + fn redo(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Redo)); } + #[method(validateMenuItem:)] fn validate_menu_item(&self, item: &NSMenuItem) -> objc2::runtime::Bool { - if unsafe { item.action() } != Some(sel!(choose:)) { + let choice = usize::try_from(unsafe { item.tag() }).ok().and_then(|tag| commands::choices().nth(tag)); + let action = unsafe { item.action() }; + if action != Some(sel!(choose:)) + && !(action.is_some() && choice.as_ref().is_some_and(|choice| crate::menubar::editing_action(choice) == action)) + { // AppKit's own items validate as NSApplication does. return objc2::runtime::Bool::new( !NSApplication::class().responds_to(sel!(validateMenuItem:)) @@ -1500,7 +1559,7 @@ declare_class!( STATUSES.with_borrow(|statuses| statuses.get(tag).copied()) }).unwrap_or_default(); unsafe { item.setState(isize::from(status.checked == Some(true))) }; - objc2::runtime::Bool::new(status.enabled) + objc2::runtime::Bool::new(status.enabled && choice.as_ref().is_some_and(commands_allowed)) } // NSAlert consumes Escape before it reaches cancelOperation:, and 10.6's date diff --git a/crates/snowbound/src/menubar.rs b/crates/snowbound/src/menubar.rs index 66e9ec66c324e5b055ac40350d7429eb9a8d0581..0902aa5c0a966afb58d313cab52841f8afc5cbe6 100644 --- a/crates/snowbound/src/menubar.rs +++ b/crates/snowbound/src/menubar.rs @@ -239,7 +239,7 @@ pub struct Menus { pub tags: Retained, } -/// Builds the bar; the table's items send `choose:` to `target`, which validates them. +/// Builds the bar; native editing follows the responder chain before the application. pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus { let services = NSMenu::new(mtm); let bar = NSMenu::new(mtm); @@ -270,6 +270,18 @@ pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus { } } +pub(crate) fn editing_action(choice: &Choice) -> Option { + Some(match choice { + Choice::Command(Id::Cut) => objc2::sel!(cut:), + Choice::Command(Id::Copy) => objc2::sel!(copy:), + Choice::Command(Id::Paste) => objc2::sel!(paste:), + Choice::Command(Id::SelectAll) => objc2::sel!(selectAll:), + Choice::Command(Id::Undo) => objc2::sel!(undo:), + Choice::Command(Id::Redo) => objc2::sel!(redo:), + _ => return None, + }) +} + fn menu( mtm: MainThreadMarker, target: &AnyObject, @@ -279,10 +291,15 @@ fn menu( ) -> Retained { let menu = unsafe { NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str(title)) }; let choose = |choice: &Choice, title: &str, chord: Option| { - let item = entry(mtm, title, Some(objc2::sel!(choose:)), chord); - // The application validates and runs the table's items whatever holds focus. + let editing = editing_action(choice); + let item = entry( + mtm, + title, + Some(editing.unwrap_or(objc2::sel!(choose:))), + chord, + ); unsafe { - item.setTarget(Some(target)); + item.setTarget(editing.is_none().then_some(target)); item.setTag(commands::index(choice) as isize); } menu.addItem(&item); @@ -450,12 +467,18 @@ mod tests { let action = unsafe { item.action() } .filter(|action| *action != objc2::sel!(submenuAction:)) .map_or(String::new(), |action| { - if action == objc2::sel!(choose:) { + if action == objc2::sel!(choose:) + || commands::choices() + .nth(unsafe { item.tag() } as usize) + .is_some_and(|choice| editing_action(&choice) == Some(action)) + { let tag = unsafe { item.tag() } as usize; - format!( - "{:?}", - commands::choices().nth(tag).expect("Tags index choices") - ) + let choice = commands::choices().nth(tag).expect("Tags index choices"); + assert_eq!( + unsafe { item.target() }.is_none(), + editing_action(&choice).is_some() + ); + format!("{choice:?}") } else { action.name().to_owned() }