diff --git a/.gitignore b/.gitignore index 3eef7b937..9108c660c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ plans/ /target .DS_Store /.claude/ +.idea/ diff --git a/Cargo.lock b/Cargo.lock index 97fa16bed..3bd7e713c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,6 +379,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "craft-quit" +version = "0.1.0" +dependencies = [ + "dispatch2", + "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] + [[package]] name = "craft-ui" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6880b4f8d..f17a3a252 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ repository = "https://github.com/storytold/craft-libs" [workspace.dependencies] # internal: add each crate here as `craft- = { path = "crates/" }` craft-print = { path = "crates/print" } +craft-quit = { path = "crates/quit" } craft-ui = { path = "crates/ui" } egui = { version = "0.36", default-features = false } diff --git a/crates/quit/Cargo.toml b/crates/quit/Cargo.toml new file mode 100644 index 000000000..0c7729419 --- /dev/null +++ b/crates/quit/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "craft-quit" +description = "Quit a Craft app the platform's own way: on macOS through `-[NSApplication terminate:]`." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[features] +# `on_close_request`: route an egui/eframe window close through the platform quit. +egui = ["dep:egui"] + +[dependencies] +egui = { workspace = true, optional = true } + +# Already in the apps' Cargo.lock: objc2 and objc2-app-kit through eframe, dispatch2 through its other dependencies. +[target.'cfg(target_os = "macos")'.dependencies] +dispatch2 = { version = "0.3.1", default-features = false, features = ["std"] } +objc2 = { version = "0.6.4", default-features = false, features = ["std"] } +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder"] } + +[lints] +workspace = true diff --git a/crates/quit/src/close.rs b/crates/quit/src/close.rs new file mode 100644 index 000000000..c3cb6d22b --- /dev/null +++ b/crates/quit/src/close.rs @@ -0,0 +1,97 @@ +//! Route an egui window close through the platform quit. + +use egui::{Context, ViewportCommand, ViewportId}; + +/// Call once per frame, after the app has dealt with unsaved work: when the root window was asked +/// to close (close button, the app's own `ViewportCommand::Close`, the OS) and this platform has +/// its own quit (see [`crate::quit`]), cancels the close and quits that way instead. The window +/// stays for the quit to close. +/// +/// Returns whether it took the close over. `false` means there was no close request, the app had +/// already cancelled it this frame, or the platform has no quit of its own, and eframe goes on as +/// it would without this call. +pub fn on_close_request(ctx: &Context) -> bool { + take_over_close(ctx, crate::quit) +} + +/// [`on_close_request`] with the platform quit passed in. `quit` returns whether it quits. +fn take_over_close(ctx: &Context, quit: impl FnOnce() -> bool) -> bool { + if ctx.viewport_id() != ViewportId::ROOT || !ctx.input(|i| i.viewport().close_requested()) { + return false; + } + // The app's unsaved-changes prompt (or anything else) is holding the window open. + if ctx.viewport(|v| v.commands.contains(&ViewportCommand::CancelClose)) { + return false; + } + if !quit() { + return false; + } + ctx.send_viewport_cmd(ViewportCommand::CancelClose); + true +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use egui::{RawInput, ViewportEvent}; + + use super::*; + + /// One frame of the root window, with its close button pressed or not. `app` runs before the + /// take-over, like the app's own unsaved-changes check. Returns whether the close was taken + /// over, how often the quit ran and the frame's commands for the root window. + fn frame(close: bool, quits: bool, app: impl Fn(&Context)) -> (bool, u32, Vec) { + let ctx = Context::default(); + let mut input = RawInput::default(); + if close { + input.viewports.entry(ViewportId::ROOT).or_default().events.push(ViewportEvent::Close); + } + let (taken, quit_calls) = (Cell::new(false), Cell::new(0)); + let mut out = ctx.run_ui(input, |ui| { + app(ui.ctx()); + taken.set(take_over_close(ui.ctx(), || { + quit_calls.set(quit_calls.get() + 1); + quits + })); + }); + out.textures_delta.clear(); // not painted + let commands = out.viewport_output.remove(&ViewportId::ROOT).map(|o| o.commands).unwrap_or_default(); + (taken.get(), quit_calls.get(), commands) + } + + fn cancels(commands: &[ViewportCommand]) -> bool { + commands.contains(&ViewportCommand::CancelClose) + } + + #[test] + fn a_close_request_quits_and_keeps_the_window() { + let (taken, quit_calls, commands) = frame(true, true, |_| {}); + assert!(taken); + assert_eq!(quit_calls, 1); + assert!(cancels(&commands), "the window stays for the quit to close"); + } + + #[test] + fn nothing_happens_without_a_close_request() { + let (taken, quit_calls, commands) = frame(false, true, |_| {}); + assert!(!taken); + assert_eq!(quit_calls, 0); + assert!(!cancels(&commands)); + } + + #[test] + fn a_close_the_app_cancelled_does_not_quit() { + let (taken, quit_calls, _) = frame(true, true, |ctx| ctx.send_viewport_cmd(ViewportCommand::CancelClose)); + assert!(!taken); + assert_eq!(quit_calls, 0, "unsaved work asks first"); + } + + #[test] + fn without_a_platform_quit_the_window_closes_as_usual() { + let (taken, quit_calls, commands) = frame(true, false, |_| {}); + assert!(!taken); + assert_eq!(quit_calls, 1); + assert!(!cancels(&commands), "eframe closes the window"); + } +} diff --git a/crates/quit/src/lib.rs b/crates/quit/src/lib.rs new file mode 100644 index 000000000..e04bbed48 --- /dev/null +++ b/crates/quit/src/lib.rs @@ -0,0 +1,58 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Quit a Craft app the platform's own way. +//! +//! On macOS an eframe app must not leave by closing its window. eframe drops the window while +//! AppKit's run loop is still running, and in the next display cycle AppKit's Touch Bar finder +//! tries to remove a KVO observer from the view that is gone +//! (`-[_NSTouchBarFinderObservation invalidate]`). That is an uncaught Objective-C exception and +//! the app crashes (seen on macOS 27 on a Touch Bar Mac). +//! `-[NSApplication terminate:]`, what ⌘Q does in winit's default menu, ends the process before +//! another display cycle runs. +//! +//! [`quit`] is that quit. With the `egui` feature, [`on_close_request`] routes a window close +//! through it; call it once per frame after the app's own unsaved-changes check: +//! +//! ```ignore +//! fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { +//! if ctx.input(|i| i.viewport().close_requested()) && self.has_unsaved_changes() { +//! ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); +//! self.ask_to_save(); // sends `ViewportCommand::Close` once answered +//! } else { +//! craft_quit::on_close_request(ctx); +//! } +//! } +//! ``` +//! +//! The app still saves what it keeps in `eframe::App::on_exit`: winit's +//! `applicationWillTerminate:` runs eframe's exit handling before the process ends. The process +//! ends there, so code after `eframe::run_native` in `main` does not run; move it to `on_exit`. + +#[cfg(feature = "egui")] +mod close; +#[cfg(target_os = "macos")] +mod macos; + +#[cfg(feature = "egui")] +pub use close::on_close_request; + +/// Quit through the platform's own path, where leaving by closing the window is not safe. +/// +/// macOS: schedules `-[NSApplication terminate:]` and returns `true`; the caller keeps its window +/// open and the process ends shortly after, once the current event has been handled. Must be +/// called while the app's event loop is running (any eframe callback is). Return to the event loop +/// before blocking: a blocking native dialog opened in the same callback would run the quit while +/// winit is still handling the event, which panics. +/// +/// Other platforms: does nothing and returns `false`; the caller closes its window as usual. +pub fn quit() -> bool { + #[cfg(target_os = "macos")] + { + macos::terminate_later(); + true + } + #[cfg(not(target_os = "macos"))] + { + false + } +} diff --git a/crates/quit/src/macos.rs b/crates/quit/src/macos.rs new file mode 100644 index 000000000..3a61976d0 --- /dev/null +++ b/crates/quit/src/macos.rs @@ -0,0 +1,19 @@ +//! `-[NSApplication terminate:]`, scheduled on the main queue. All safe objc2 calls. + +use dispatch2::DispatchQueue; +use objc2::MainThreadMarker; +use objc2_app_kit::NSApplication; + +/// Send `terminate:` from the main queue rather than from the caller. The caller is inside winit's +/// event handler, and `terminate:` makes winit deliver its exit event, which it can't do while +/// another event is being handled. From the main queue winit is idle: its +/// `applicationWillTerminate:` closes the windows and runs the app's exit handling (eframe saves +/// and calls `on_exit`), and AppKit ends the process. +pub(crate) fn terminate_later() { + DispatchQueue::main().exec_async(|| { + // The main queue runs on the main thread, so there is always a marker. + if let Some(mtm) = MainThreadMarker::new() { + NSApplication::sharedApplication(mtm).terminate(None); + } + }); +}