mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-09 03:51:47 +00:00
201 lines
8.7 KiB
Rust
201 lines
8.7 KiB
Rust
//! Route an egui window close through the platform quit.
|
|
|
|
use egui::{Context, ViewportCommand, ViewportId};
|
|
|
|
/// Call once per frame at the end of `eframe::App::logic`, after the app's own close decision.
|
|
/// Move that decision out of `ui` as well: `logic` runs first, and `ui` does not run while the
|
|
/// window is minimized or hidden. See the crate example for keeping a save prompt in app state.
|
|
/// 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)
|
|
}
|
|
|
|
/// The id of a Quit menu item that asks the app first.
|
|
///
|
|
/// An app that builds its own native app menu (muda, any version) gives its Quit item this id,
|
|
/// instead of using muda's `PredefinedMenuItem::quit`, which ends the process without asking, and
|
|
/// passes its menu events to [`on_menu_event`]. Apps without their own menu use
|
|
/// [`crate::install_app_menu`], which does both.
|
|
pub const QUIT_MENU_ID: &str = "craft-quit.quit";
|
|
|
|
/// Handle a native menu event by the id of the item chosen. For [`QUIT_MENU_ID`], asks the root
|
|
/// window to close (`ViewportCommand::Close`), so the app's unsaved-changes check runs as it does
|
|
/// for the close button and [`on_close_request`] then quits. Returns whether the item was Quit;
|
|
/// other items are left to the caller. Safe to call from a menu-event handler between frames:
|
|
/// egui keeps the command for the next frame.
|
|
pub fn on_menu_event(ctx: &Context, id: &str) -> bool {
|
|
if id != QUIT_MENU_ID {
|
|
return false;
|
|
}
|
|
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::Close);
|
|
true
|
|
}
|
|
|
|
/// [`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<ViewportCommand>) {
|
|
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");
|
|
}
|
|
|
|
/// The root window's commands from one frame run after `before`, which runs between frames
|
|
/// like a native menu-event handler.
|
|
fn commands_after(before: impl FnOnce(&Context)) -> Vec<ViewportCommand> {
|
|
let ctx = Context::default();
|
|
before(&ctx);
|
|
let mut out = ctx.run_ui(RawInput::default(), |_| {});
|
|
out.textures_delta.clear(); // not painted
|
|
out.viewport_output.remove(&ViewportId::ROOT).map(|o| o.commands).unwrap_or_default()
|
|
}
|
|
|
|
#[test]
|
|
fn the_quit_menu_item_asks_the_window_to_close() {
|
|
let commands = commands_after(|ctx| assert!(on_menu_event(ctx, QUIT_MENU_ID)));
|
|
assert!(commands.contains(&ViewportCommand::Close), "the app's close handling decides");
|
|
}
|
|
|
|
#[test]
|
|
fn quit_from_a_child_viewport_still_asks_the_root_to_close() {
|
|
let ctx = Context::default();
|
|
let child = ViewportId::from_hash_of("document-child");
|
|
let mut input = RawInput { viewport_id: child, ..RawInput::default() };
|
|
input.viewports.entry(child).or_default().parent = Some(ViewportId::ROOT);
|
|
let mut out = ctx.run_ui(input, |ui| {
|
|
assert_eq!(ui.ctx().viewport_id(), child);
|
|
assert!(on_menu_event(ui.ctx(), QUIT_MENU_ID));
|
|
});
|
|
out.textures_delta.clear(); // not painted
|
|
let root_commands = out.viewport_output.get(&ViewportId::ROOT).map(|o| o.commands.as_slice()).unwrap_or_default();
|
|
let child_commands = out.viewport_output.get(&child).map(|o| o.commands.as_slice()).unwrap_or_default();
|
|
assert!(root_commands.contains(&ViewportCommand::Close), "root unsaved handling decides whether the app exits");
|
|
assert!(!child_commands.contains(&ViewportCommand::Close), "Quit must not just close the current child");
|
|
}
|
|
|
|
#[test]
|
|
fn other_menu_items_are_left_to_the_app() {
|
|
let commands = commands_after(|ctx| assert!(!on_menu_event(ctx, "edit.copy")));
|
|
assert!(!commands.contains(&ViewportCommand::Close));
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
|
|
/// eframe uses only the logic callback while minimized; no UI callback runs here.
|
|
fn minimized_logic_frame(unsaved: bool) -> (bool, u32, bool, Vec<ViewportCommand>) {
|
|
let ctx = Context::default();
|
|
let mut input = RawInput::default();
|
|
let root = input.viewports.entry(ViewportId::ROOT).or_default();
|
|
root.minimized = Some(true);
|
|
root.events.push(ViewportEvent::Close);
|
|
let (taken, quit_calls, show_save_prompt) = (Cell::new(false), Cell::new(0), Cell::new(false));
|
|
let mut out = ctx.run_logic(&input, |ctx| {
|
|
if ctx.input(|i| i.viewport().close_requested()) && unsaved {
|
|
ctx.send_viewport_cmd(ViewportCommand::CancelClose);
|
|
show_save_prompt.set(true);
|
|
}
|
|
taken.set(take_over_close(ctx, || {
|
|
quit_calls.set(quit_calls.get() + 1);
|
|
true
|
|
}));
|
|
});
|
|
let commands = out.viewport_commands.remove(&ViewportId::ROOT).unwrap_or_default();
|
|
(taken.get(), quit_calls.get(), show_save_prompt.get(), commands)
|
|
}
|
|
|
|
#[test]
|
|
fn minimized_unsaved_close_waits_for_the_prompt_without_running_ui() {
|
|
let (taken, quit_calls, show_save_prompt, commands) = minimized_logic_frame(true);
|
|
assert!(!taken);
|
|
assert_eq!(quit_calls, 0);
|
|
assert!(show_save_prompt, "UI can render the decision stored by logic");
|
|
assert!(cancels(&commands));
|
|
}
|
|
|
|
#[test]
|
|
fn minimized_clean_close_takes_over_without_running_ui() {
|
|
let (taken, quit_calls, show_save_prompt, commands) = minimized_logic_frame(false);
|
|
assert!(taken);
|
|
assert_eq!(quit_calls, 1);
|
|
assert!(!show_save_prompt);
|
|
assert!(cancels(&commands), "eframe must not tear down the window before platform quit");
|
|
}
|
|
}
|