From a30644c872d1f27a1ad670f79da25c22330851a2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 1 Nov 2026 04:26:47 +0000 Subject: [PATCH 1/2] fix(quit): bring the asking window to the front on a Dock quit Quit from the Dock reaches GridCraft while it is behind other apps, hidden or minimized. The Save Changes question opened in the workbook's window, but nothing brought that window forward when it was already the active one, so the app looked stuck. The window is restored, shown and focused in that order, since macOS ignores Focus for a minimized window or a hidden app. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/quit.rs | 1 + crates/ui-egui/src/doc_windows.rs | 9 +++++++++ crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/tests/doc_windows.rs | 17 +++++++++++++++++ 5 files changed, 31 insertions(+) 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); From 7ed8a9f3128626b152c290ba24353d5e04d770ab Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 1 Nov 2026 04:30:17 +0000 Subject: [PATCH 2/2] fix(quit): bring GridCraft forward through AppKit for a Dock quit egui's Visible/Minimized/Focus go through winit, whose macOS focus_window skips a minimized window or a hidden app, and macOS can refuse the activation. When a Dock quit opens a question, unhide the app, deminiaturize its windows and activate it through AppKit, and bounce the Dock icon if it still isn't active. --- apps/gridcraft/Cargo.toml | 4 ++++ apps/gridcraft/src/apple_events.rs | 29 ++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) 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); + } +}