authorclover caruso <git@paperclover.net> 2026-10-12 16:32:46-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 02:58:22-07:00
logc21fb63b8d789060e2ff38951cd910a734696ca7
treed1c49d1eaef6442e244065ec7c6bc7d60ce5337c
parent6bf6a20c4073e708ed209be99349432815b693ce
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(desktop): keep document commands out of native macOS dialogs

Refs #129 Assisted-by: gpt-6.1-sol

4 files changed, 112 insertions(+), 19 deletions(-)

crates/snowbound/Cargo.toml+1-1
......@@ -64,7 +64,7 @@ web-sys = { version = "0.3", features = ["console", "Document", "Element", "Html
6464[target.'cfg(target_os = "macos")'.dependencies]
6565libc = "0.2"
6666objc2 = "0.5.2"
67objc2-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"] }
67objc2-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"] }
6868objc2-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"] }
6969block2 = "0.5.1"
7070
crates/snowbound/src/commands.rs+13-2
......@@ -1416,6 +1416,10 @@ impl State {
14161416
14171417 /// Runs `choice` where it applies now.
14181418 pub(crate) fn run(&mut self, choice: Choice) -> Result<(), Box<dyn Error>> {
1419 #[cfg(target_os = "macos")]
1420 if !platform::commands_allowed(&choice) {
1421 return Ok(());
1422 }
14191423 if !self.status(&choice, &self.format_state()).enabled {
14201424 return Ok(());
14211425 }
......@@ -1812,8 +1816,15 @@ impl State {
18121816 return Ok(());
18131817 }
18141818 };
1815 self.commands.push(response);
1816 Ok(())
1819 #[cfg(target_os = "macos")]
1820 {
1821 self.apply(response)
1822 }
1823 #[cfg(not(target_os = "macos"))]
1824 {
1825 self.commands.push(response);
1826 Ok(())
1827 }
18171828 }
18181829}
18191830
crates/snowbound/src/macos.rs+66-7
......@@ -9,9 +9,10 @@ use objc2::{
99 sel,
1010};
1111use objc2_app_kit::{
12 NSAlert, NSAlertFirstButtonReturn, NSApplication, NSColor, NSColorSpace, NSDatePicker,
13 NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventSubtype, NSEventType, NSMenu,
14 NSMenuItem, NSModalResponse, NSModalResponseCancel, NSModalResponseOK, NSSavePanel, NSWindow,
12 NSAlert, NSAlertFirstButtonReturn, NSApplication, NSApplicationActivationPolicy, NSColor,
13 NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent,
14 NSEventSubtype, NSEventType, NSMenu, NSMenuItem, NSModalResponse, NSModalResponseCancel,
15 NSModalResponseOK, NSSavePanel, NSWindow,
1516};
1617use objc2_foundation::{
1718 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,
......@@ -1454,6 +1455,42 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {
14541455 }
14551456}
14561457
1458pub(crate) fn commands_allowed(choice: &commands::Choice) -> bool {
1459 if matches!(
1460 choice,
1461 commands::Choice::Command(commands::Id::Settings | commands::Id::CheckForUpdates)
1462 ) {
1463 return true;
1464 }
1465 let app = NSApplication::sharedApplication(
1466 MainThreadMarker::new().expect("Commands run on the main thread"),
1467 );
1468 WINDOW.with_borrow(|window| {
1469 let Some(window) = window else { return false };
1470 unsafe {
1471 if app.modalWindow().is_some() || window.attachedSheet().is_some() {
1472 return false;
1473 }
1474 match app.keyWindow() {
1475 Some(key) => std::ptr::eq(&*key, &**window),
1476 // Hidden screenshot replays have no key window and cannot activate.
1477 None => {
1478 !window.isVisible()
1479 && app.activationPolicy() == NSApplicationActivationPolicy::Prohibited
1480 }
1481 }
1482 }
1483 })
1484}
1485
1486fn send_choice(choice: commands::Choice) {
1487 if commands_allowed(&choice)
1488 && let Some(proxy) = PROXY.get()
1489 {
1490 let _ = proxy.send_event(crate::UserEvent::Choose(choice));
1491 }
1492}
1493
14571494declare_class!(
14581495 struct CanvasApplication;
14591496
......@@ -1482,14 +1519,36 @@ declare_class!(
14821519 #[method(choose:)]
14831520 fn choose(&self, sender: &NSMenuItem) {
14841521 let tag = usize::try_from(unsafe { sender.tag() }).ok();
1485 if let (Some(proxy), Some(choice)) = (PROXY.get(), tag.and_then(|tag| commands::choices().nth(tag))) {
1486 let _ = proxy.send_event(crate::UserEvent::Choose(choice));
1522 if let Some(choice) = tag.and_then(|tag| commands::choices().nth(tag)) {
1523 send_choice(choice);
14871524 }
14881525 }
14891526
1527 #[method(cut:)]
1528 fn cut(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Cut)); }
1529
1530 #[method(copy:)]
1531 fn copy(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Copy)); }
1532
1533 #[method(paste:)]
1534 fn paste(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Paste)); }
1535
1536 #[method(selectAll:)]
1537 fn select_all(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::SelectAll)); }
1538
1539 #[method(undo:)]
1540 fn undo(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Undo)); }
1541
1542 #[method(redo:)]
1543 fn redo(&self, _sender: Option<&AnyObject>) { send_choice(commands::Choice::Command(commands::Id::Redo)); }
1544
14901545 #[method(validateMenuItem:)]
14911546 fn validate_menu_item(&self, item: &NSMenuItem) -> objc2::runtime::Bool {
1492 if unsafe { item.action() } != Some(sel!(choose:)) {
1547 let choice = usize::try_from(unsafe { item.tag() }).ok().and_then(|tag| commands::choices().nth(tag));
1548 let action = unsafe { item.action() };
1549 if action != Some(sel!(choose:))
1550 && !(action.is_some() && choice.as_ref().is_some_and(|choice| crate::menubar::editing_action(choice) == action))
1551 {
14931552 // AppKit's own items validate as NSApplication does.
14941553 return objc2::runtime::Bool::new(
14951554 !NSApplication::class().responds_to(sel!(validateMenuItem:))
......@@ -1500,7 +1559,7 @@ declare_class!(
15001559 STATUSES.with_borrow(|statuses| statuses.get(tag).copied())
15011560 }).unwrap_or_default();
15021561 unsafe { item.setState(isize::from(status.checked == Some(true))) };
1503 objc2::runtime::Bool::new(status.enabled)
1562 objc2::runtime::Bool::new(status.enabled && choice.as_ref().is_some_and(commands_allowed))
15041563 }
15051564
15061565 // NSAlert consumes Escape before it reaches cancelOperation:, and 10.6's date
crates/snowbound/src/menubar.rs+32-9
......@@ -239,7 +239,7 @@ pub struct Menus {
239239 pub tags: Retained<NSMenu>,
240240}
241241
242/// Builds the bar; the table's items send `choose:` to `target`, which validates them.
242/// Builds the bar; native editing follows the responder chain before the application.
243243pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {
244244 let services = NSMenu::new(mtm);
245245 let bar = NSMenu::new(mtm);
......@@ -270,6 +270,18 @@ pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {
270270 }
271271}
272272
273pub(crate) fn editing_action(choice: &Choice) -> Option<Sel> {
274 Some(match choice {
275 Choice::Command(Id::Cut) => objc2::sel!(cut:),
276 Choice::Command(Id::Copy) => objc2::sel!(copy:),
277 Choice::Command(Id::Paste) => objc2::sel!(paste:),
278 Choice::Command(Id::SelectAll) => objc2::sel!(selectAll:),
279 Choice::Command(Id::Undo) => objc2::sel!(undo:),
280 Choice::Command(Id::Redo) => objc2::sel!(redo:),
281 _ => return None,
282 })
283}
284
273285fn menu(
274286 mtm: MainThreadMarker,
275287 target: &AnyObject,
......@@ -279,10 +291,15 @@ fn menu(
279291) -> Retained<NSMenu> {
280292 let menu = unsafe { NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str(title)) };
281293 let choose = |choice: &Choice, title: &str, chord: Option<Chord>| {
282 let item = entry(mtm, title, Some(objc2::sel!(choose:)), chord);
283 // The application validates and runs the table's items whatever holds focus.
294 let editing = editing_action(choice);
295 let item = entry(
296 mtm,
297 title,
298 Some(editing.unwrap_or(objc2::sel!(choose:))),
299 chord,
300 );
284301 unsafe {
285 item.setTarget(Some(target));
302 item.setTarget(editing.is_none().then_some(target));
286303 item.setTag(commands::index(choice) as isize);
287304 }
288305 menu.addItem(&item);
......@@ -450,12 +467,18 @@ mod tests {
450467 let action = unsafe { item.action() }
451468 .filter(|action| *action != objc2::sel!(submenuAction:))
452469 .map_or(String::new(), |action| {
453 if action == objc2::sel!(choose:) {
470 if action == objc2::sel!(choose:)
471 || commands::choices()
472 .nth(unsafe { item.tag() } as usize)
473 .is_some_and(|choice| editing_action(&choice) == Some(action))
474 {
454475 let tag = unsafe { item.tag() } as usize;
455 format!(
456 "{:?}",
457 commands::choices().nth(tag).expect("Tags index choices")
458 )
476 let choice = commands::choices().nth(tag).expect("Tags index choices");
477 assert_eq!(
478 unsafe { item.target() }.is_none(),
479 editing_action(&choice).is_some()
480 );
481 format!("{choice:?}")
459482 } else {
460483 action.name().to_owned()
461484 }