| author | |
| committer | |
| log | c21fb63b8d789060e2ff38951cd910a734696ca7 |
| tree | d1c49d1eaef6442e244065ec7c6bc7d60ce5337c |
| parent | 6bf6a20c4073e708ed209be99349432815b693ce |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
Refs #129
Assisted-by: gpt-6.1-sol4 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] |
| 65 | libc = "0.2" | 65 | libc = "0.2" |
| 66 | objc2 = "0.5.2" | 66 | objc2 = "0.5.2" |
| 67 | 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"] } | 67 | 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"] } |
| 68 | 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"] } | 68 | 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"] } |
| 69 | block2 = "0.5.1" | 69 | block2 = "0.5.1" |
| 70 | 70 |
crates/snowbound/src/commands.rs+13-2| ... | @@ -1416,6 +1416,10 @@ impl State { | ... | @@ -1416,6 +1416,10 @@ impl State { |
| 1416 | 1416 | ||
| 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 | } |
| 1819 | 1830 |
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 | }; |
| 11 | use objc2_app_kit::{ | 11 | use 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 | }; |
| 16 | use objc2_foundation::{ | 17 | use 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 | } |
| 1456 | 1457 | ||
| 1458 | pub(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 | |||
| 1486 | fn 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 | |||
| 1457 | declare_class!( | 1494 | declare_class!( |
| 1458 | struct CanvasApplication; | 1495 | struct CanvasApplication; |
| 1459 | 1496 | ||
| ... | @@ -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 | } |
| 1489 | 1526 | ||
| 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 | } |
| 1505 | 1564 | ||
| 1506 | // NSAlert consumes Escape before it reaches cancelOperation:, and 10.6's date | 1565 | // 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 | } |
| 241 | 241 | ||
| 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. |
| 243 | pub fn build(mtm: MainThreadMarker, target: &AnyObject) -> Menus { | 243 | pub 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 | } |
| 272 | 272 | ||
| 273 | pub(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 | |||
| 273 | fn menu( | 285 | fn 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 | } |