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...@@ -64,7 +64,7 @@ web-sys = { version = "0.3", features = ["console", "Document", "Element", "Html
64[target.'cfg(target_os = "macos")'.dependencies]64[target.'cfg(target_os = "macos")'.dependencies]
65libc = "0.2"65libc = "0.2"
66objc2 = "0.5.2"66objc2 = "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"] }
68objc2-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"] }68objc2-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"] }
69block2 = "0.5.1"69block2 = "0.5.1"
7070
crates/snowbound/src/commands.rs+13-2
...@@ -1416,6 +1416,10 @@ impl State {...@@ -1416,6 +1416,10 @@ impl State {
14161416
1417 /// Runs `choice` where it applies now.1417 /// Runs `choice` where it applies now.
1418 pub(crate) fn run(&mut self, choice: Choice) -> Result<(), Box<dyn Error>> {1418 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 }
1419 if !self.status(&choice, &self.format_state()).enabled {1423 if !self.status(&choice, &self.format_state()).enabled {
1420 return Ok(());1424 return Ok(());
1421 }1425 }
...@@ -1812,8 +1816,15 @@ impl State {...@@ -1812,8 +1816,15 @@ impl State {
1812 return Ok(());1816 return Ok(());
1813 }1817 }
1814 };1818 };
1815 self.commands.push(response);1819 #[cfg(target_os = "macos")]
1816 Ok(())1820 {
1821 self.apply(response)
1822 }
1823 #[cfg(not(target_os = "macos"))]
1824 {
1825 self.commands.push(response);
1826 Ok(())
1827 }
1817 }1828 }
1818}1829}
18191830
crates/snowbound/src/macos.rs+66-7
...@@ -9,9 +9,10 @@ use objc2::{...@@ -9,9 +9,10 @@ use objc2::{
9 sel,9 sel,
10};10};
11use objc2_app_kit::{11use objc2_app_kit::{
12 NSAlert, NSAlertFirstButtonReturn, NSApplication, NSColor, NSColorSpace, NSDatePicker,12 NSAlert, NSAlertFirstButtonReturn, NSApplication, NSApplicationActivationPolicy, NSColor,
13 NSDatePickerElementFlags, NSDatePickerStyle, NSEvent, NSEventSubtype, NSEventType, NSMenu,13 NSColorSpace, NSDatePicker, NSDatePickerElementFlags, NSDatePickerStyle, NSEvent,
14 NSMenuItem, NSModalResponse, NSModalResponseCancel, NSModalResponseOK, NSSavePanel, NSWindow,14 NSEventSubtype, NSEventType, NSMenu, NSMenuItem, NSModalResponse, NSModalResponseCancel,
15 NSModalResponseOK, NSSavePanel, NSWindow,
15};16};
16use objc2_foundation::{17use objc2_foundation::{
17 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,18 MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter,
...@@ -1454,6 +1455,42 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {...@@ -1454,6 +1455,42 @@ pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {
1454 }1455 }
1455}1456}
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
1457declare_class!(1494declare_class!(
1458 struct CanvasApplication;1495 struct CanvasApplication;
14591496
...@@ -1482,14 +1519,36 @@ declare_class!(...@@ -1482,14 +1519,36 @@ declare_class!(
1482 #[method(choose:)]1519 #[method(choose:)]
1483 fn choose(&self, sender: &NSMenuItem) {1520 fn choose(&self, sender: &NSMenuItem) {
1484 let tag = usize::try_from(unsafe { sender.tag() }).ok();1521 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))) {1522 if let Some(choice) = tag.and_then(|tag| commands::choices().nth(tag)) {
1486 let _ = proxy.send_event(crate::UserEvent::Choose(choice));1523 send_choice(choice);
1487 }1524 }
1488 }1525 }
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
1490 #[method(validateMenuItem:)]1545 #[method(validateMenuItem:)]
1491 fn validate_menu_item(&self, item: &NSMenuItem) -> objc2::runtime::Bool {1546 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 {
1493 // AppKit's own items validate as NSApplication does.1552 // AppKit's own items validate as NSApplication does.
1494 return objc2::runtime::Bool::new(1553 return objc2::runtime::Bool::new(
1495 !NSApplication::class().responds_to(sel!(validateMenuItem:))1554 !NSApplication::class().responds_to(sel!(validateMenuItem:))
...@@ -1500,7 +1559,7 @@ declare_class!(...@@ -1500,7 +1559,7 @@ declare_class!(
1500 STATUSES.with_borrow(|statuses| statuses.get(tag).copied())1559 STATUSES.with_borrow(|statuses| statuses.get(tag).copied())
1501 }).unwrap_or_default();1560 }).unwrap_or_default();
1502 unsafe { item.setState(isize::from(status.checked == Some(true))) };1561 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))
1504 }1563 }
15051564
1506 // NSAlert consumes Escape before it reaches cancelOperation:, and 10.6's date1565 // 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 {...@@ -239,7 +239,7 @@ pub struct Menus {
239 pub tags: Retained<NSMenu>,239 pub tags: Retained<NSMenu>,
240}240}
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.
243pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {243pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {
244 let services = NSMenu::new(mtm);244 let services = NSMenu::new(mtm);
245 let bar = NSMenu::new(mtm);245 let bar = NSMenu::new(mtm);
...@@ -270,6 +270,18 @@ pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {...@@ -270,6 +270,18 @@ pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus {
270 }270 }
271}271}
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
273fn menu(285fn menu(
274 mtm: MainThreadMarker,286 mtm: MainThreadMarker,
275 target: &AnyObject,287 target: &AnyObject,
...@@ -279,10 +291,15 @@ fn menu(...@@ -279,10 +291,15 @@ fn menu(
279) -> Retained<NSMenu> {291) -> Retained<NSMenu> {
280 let menu = unsafe { NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str(title)) };292 let menu = unsafe { NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str(title)) };
281 let choose = |choice: &Choice, title: &str, chord: Option<Chord>| {293 let choose = |choice: &Choice, title: &str, chord: Option<Chord>| {
282 let item = entry(mtm, title, Some(objc2::sel!(choose:)), chord);294 let editing = editing_action(choice);
283 // The application validates and runs the table's items whatever holds focus.295 let item = entry(
296 mtm,
297 title,
298 Some(editing.unwrap_or(objc2::sel!(choose:))),
299 chord,
300 );
284 unsafe {301 unsafe {
285 item.setTarget(Some(target));302 item.setTarget(editing.is_none().then_some(target));
286 item.setTag(commands::index(choice) as isize);303 item.setTag(commands::index(choice) as isize);
287 }304 }
288 menu.addItem(&item);305 menu.addItem(&item);
...@@ -450,12 +467,18 @@ mod tests {...@@ -450,12 +467,18 @@ mod tests {
450 let action = unsafe { item.action() }467 let action = unsafe { item.action() }
451 .filter(|action| *action != objc2::sel!(submenuAction:))468 .filter(|action| *action != objc2::sel!(submenuAction:))
452 .map_or(String::new(), |action| {469 .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 {
454 let tag = unsafe { item.tag() } as usize;475 let tag = unsafe { item.tag() } as usize;
455 format!(476 let choice = commands::choices().nth(tag).expect("Tags index choices");
456 "{:?}",477 assert_eq!(
457 commands::choices().nth(tag).expect("Tags index choices")478 unsafe { item.target() }.is_none(),
458 )479 editing_action(&choice).is_some()
480 );
481 format!("{choice:?}")
459 } else {482 } else {
460 action.name().to_owned()483 action.name().to_owned()
461 }484 }