diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index e5ad73da4..3082080e7 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -52,6 +52,10 @@ muda = { version = "0.21", default-features = false } # by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our # workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" +# apple_events.rs brings the app forward for a Dock quit's question. Already in Cargo.lock (muda, +# winit); pinned exactly to the same versions. +objc2 = "=0.6.5" +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] } # tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit); # pinned exactly to the same versions. diff --git a/apps/gridcraft/src/apple_events.rs b/apps/gridcraft/src/apple_events.rs index 4d596f957..b0898eced 100644 --- a/apps/gridcraft/src/apple_events.rs +++ b/apps/gridcraft/src/apple_events.rs @@ -11,6 +11,8 @@ use fmv_macos_events::{Event, Inbox, Registration}; use gridcraft_ui_egui::SheetApp; +use objc2::MainThreadMarker; +use objc2_app_kit::{NSApplication, NSRequestUserAttentionType}; /// Keeps the Apple-event handlers registered; hold it until the event loop returns. pub struct AppleEvents { @@ -44,7 +46,32 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) { } } // Quit from the Dock or the system: each unsaved workbook is asked about in its window. - Event::Quit => app.request_quit(ctx), + Event::Quit => { + app.request_quit(ctx); + if app.dialog.is_some() { + bring_app_forward(); + } + } } } } + +/// A Dock quit reaches GridCraft behind other apps, hidden or minimized, and its question has to +/// be seen. egui's Focus goes through winit, which skips a minimized window or a hidden app, and +/// macOS may refuse an activation it didn't expect, so ask AppKit directly and bounce the Dock +/// icon until the user switches over. +fn bring_app_forward() { + let Some(mtm) = MainThreadMarker::new() else { return }; + let ns_app = NSApplication::sharedApplication(mtm); + ns_app.unhide(None); + for window in ns_app.windows().to_vec() { + if window.isMiniaturized() { + window.deminiaturize(None); + } + } + #[allow(deprecated)] // `activate` needs macOS 14; this works on every supported version. + ns_app.activateIgnoringOtherApps(true); + if !ns_app.isActive() { + ns_app.requestUserAttention(NSRequestUserAttentionType::CriticalRequest); + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6fa5def4f..068be59a4 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -75,6 +75,7 @@ impl SheetApp { macros: macro_ui::MacroUi::default(), started: now_ms(), allow_close: false, + bring_forward: false, open_switch_windows: false, ui_window: None, parked: HashMap::new(), diff --git a/crates/ui-egui/src/app/quit.rs b/crates/ui-egui/src/app/quit.rs index d4479d191..ffa230c34 100644 --- a/crates/ui-egui/src/app/quit.rs +++ b/crates/ui-egui/src/app/quit.rs @@ -42,6 +42,7 @@ impl SheetApp { self.enter_window(window); let title = self.session.active().map(|d| d.display_title()).unwrap_or_default(); self.open_dialog("saveChanges", json!({"title": title, "quit": true})); + self.bring_forward = true; } None => { self.allow_close = true; diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs index 7ee1efaa1..7bf3a799d 100644 --- a/crates/ui-egui/src/doc_windows.rs +++ b/crates/ui-egui/src/doc_windows.rs @@ -160,6 +160,15 @@ impl DocWindows { self.list.focus(id); self.viewports.focus(ctx, id); } + // macOS ignores Focus for a minimized window or a hidden app, so restore and show first. + if std::mem::take(&mut app.bring_forward) + && let Some(id) = self.list.active() + { + let vp = self.viewports.viewport_id(id); + ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::Focus); + } } /// Draws every window. Call from the root viewport's UI each frame, in place of diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index db9ab316f..c5a9c0030 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -284,6 +284,9 @@ pub struct SheetApp { /// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next /// window close goes through. pub allow_close: bool, + /// A quit question is waiting in the active window: [`doc_windows`] brings its native window + /// to the front, also when the app is hidden or the window minimized (a Dock quit). + pub bring_forward: bool, /// View › Switch Windows opens its list on the next frame the View tab draws (asked for by a /// key tip, the command search or a menu, possibly while another tab was showing). pub(crate) open_switch_windows: bool, diff --git a/crates/ui-egui/tests/doc_windows.rs b/crates/ui-egui/tests/doc_windows.rs index 46427f3bf..1d72739e3 100644 --- a/crates/ui-egui/tests/doc_windows.rs +++ b/crates/ui-egui/tests/doc_windows.rs @@ -232,6 +232,23 @@ fn quit_asks_about_each_unsaved_workbook_in_its_window_and_cancel_stops() { assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "both answered: the app quits: {sent:?}"); } +#[test] +fn a_quit_question_brings_its_window_to_the_front_from_the_background() { + // A Dock quit reaches the app while it is behind other apps, hidden or minimized. + let mut desk = Desk::new(true); + desk.app.run("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + desk.frames(3); + let w = desk.window_of(0); + + let sent = desk.frame_with(|app, ctx| app.request_quit(ctx)); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + for cmd in [ViewportCommand::Visible(true), ViewportCommand::Minimized(false), ViewportCommand::Focus] { + assert!(sent_to(&sent, desk.viewport(w), &cmd), "{cmd:?} missing: {sent:?}"); + } + let sent = desk.frames(2); + assert!(!sent_to(&sent, desk.viewport(w), &ViewportCommand::Focus), "only once: {sent:?}"); +} + #[test] fn the_root_adopts_the_remaining_window_with_its_workbook_and_state() { let mut desk = Desk::new(true);