This commit is contained in:
Brandon Thomas
2026-11-01 12:18:01 -05:00
7 changed files with 63 additions and 1 deletions
+4
View File
@@ -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.
+28 -1
View File
@@ -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);
}
}
+1
View File
@@ -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(),
+1
View File
@@ -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;
+9
View File
@@ -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
+3
View File
@@ -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,
+17
View File
@@ -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);