Files
aiongg 9f89fe8f6d Add craft-menu: one menu definition, native on macOS and an egui bar on Windows, Linux and web (#66)
* Add craft-menu: Mac menu-bar layout and native-menu driver

A platform-free menu model (built directly or from flat rows), the macOS
layout (app menu, Window, Help, system-key clash resolution, translatable
labels), and NativeMenu, which feeds a backend's key equivalents to egui as
key presses, runs Minimize and craft-quit's Quit, and re-syncs only when the
app's state changes. Generalised from PhotoCraft's native_menu.rs.

* craft-menu: macOS menu bar through muda

* craft-menu: clipboard keys, layout-assigned shortcuts, Hide Others and atomic rebuilds

* craft-menu: menu key equivalents run while a text field has the keys

* craft-menu: commands as CommandRef, item kinds, craft-keymap chords

Items and rows run a craft_keymap::CommandRef and take a craft_keymap::Chord
shortcut; crates/menu/src/chord.rs is gone. Native ids are the command's key,
made unique with #2, #3 when a command is shown twice. Item.checked becomes
ItemKind { Plain, Check, Radio } (radio shows as a check item on macOS).
The built-in items' commands are Builtin::{Hide, HideOthers, Minimize, Quit}.
MenuRole gains Edit, View and Other; tag_submenu finds its menu by role.
While text editing, the keys left to the field are the clipboard keys and
craft_keymap::text_editing_chords. Adds the Platforms, Document and Gate types
(data only) and MenuBar::documents.

* craft-menu: Gate::allows, the one rule for what may run from the menus

* craft-menu: Windows, Linux and web layouts, documents in Window, keymap adapter

layout(bar, Target, options) dispatches to mac_layout, desktop_layout or
web_layout. Every layout drops what the target's Platforms leave out and lists
MenuBar::documents as radio items last in the Window menu (ItemRole::Document
is gone). Windows and Linux put About in Help, Settings in Edit as
Preferences and Quit in File (Exit on Windows); the web layout has no Quit and
takes the browser's own shortcuts off the items, reporting clashes. tidy drops
empty submenus.

apply_keymap sets shortcuts and toggle check items from a craft-keymap Keymap;
apply_state sets enabled and checked per frame.

* craft-menu: the egui-drawn menu bar for Windows, Linux and the web

* craft-menu: gate, command hook and one muda handler for the native menu

- NativeMenu::set_gate: app items greyed in place while modal or recording a
  shortcut; take_clicks drops clicks the gate forbids when they are taken.
  While recording, key equivalents reach egui as plain key presses.
- NativeMenu::on_command: a hook called from the menu event handler for each
  allowed app click; take_clicks still returns every command.
- Dispatch, shared between the driver and the backend's handler
  (Backend::attach replaces Backend::set_text_editing).
- Only Cmd or Ctrl chords become key equivalents; other shortcuts are not
  shown natively (building an attributed title needs unsafe objc2 calls).
- mac: one muda event handler per process, routed to the latest install;
  mac::install_app_menu ports craft-quit's minimal app menu onto it.
- craft_quit::install_app_menu is deprecated in favor of
  craft_menu::mac::install_app_menu.

* craft-menu: the bar takes Target, README section and NOTICE credit

* craft-menu: bar tests beside the layout and native ones; only a click outside closes the menus

* craft-menu: clippy (complex types, Copy clones, elided lifetimes)

* craft-menu: keyboard access to the egui bar

A tap on Alt alone or F10 focuses the bar, Alt+letter opens a menu and
a letter in an open menu runs or opens the row with that mnemonic, as
in a Windows menu bar. Mnemonics are assigned per level from the shown
labels, underlined only while the keyboard drives the bar, and a label
without an ASCII letter shows its English letter after it ("文件(F)")
through the optional english hook. Preferred letters come from
MenuBarUi::mnemonics. A Mac target has none, and a dialog or the
shortcut recorder keeps the keys. Ported from PhotoCraft's menu_keys.rs
and menu_keys_tests.rs.

* craft-menu: one installer or the other; sort mnemonics before checking uniqueness

* craft-menu: egui 0.37

* craft-menu: text fields keep their Edit items; web names by the browser's system; sync per frame; stable check kinds; handled hook clicks

* craft-menu: sync reports a clash change on an unchanged bar
2026-10-25 21:59:22 -04:00

106 lines
5.2 KiB
Rust

#![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. Put both the app's close decision and this hook in `eframe::App::logic`, with the
//! hook last. eframe calls `logic` even while the window is minimized or hidden, when `ui` does
//! not run. Move any unsaved-changes check out of `ui` too: `logic` runs before `ui`, so a check
//! left in `ui` would happen after the quit. Keep prompt state in the app and render it in `ui`:
//!
//! ```ignore
//! fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
//! if ctx.input(|i| i.viewport().close_requested())
//! && self.has_unsaved_changes()
//! && !self.discard_confirmed
//! {
//! ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
//! self.show_save_prompt = true;
//! }
//! craft_quit::on_close_request(ctx); // last, so it sees the app's `CancelClose`
//! }
//!
//! fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
//! // ... the app's UI, including the prompt when show_save_prompt is true ...
//! }
//! ```
//!
//! The prompt sends `ViewportCommand::Close` only after Save has succeeded (and cleared the
//! dirty state), or Don't Save has set `discard_confirmed`. Cancel, a cancelled file picker and
//! a failed save keep the app open; reset prompt/confirmation state when the decision is cancelled
//! or a new document is opened. An asynchronous save must finish before requesting Close. If the
//! window is hidden, bring it back before showing the prompt so the user can answer it.
//!
//! 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(all(feature = "menu", target_os = "macos"))]
mod menu;
#[cfg(feature = "egui")]
pub use close::{QUIT_MENU_ID, on_close_request, on_menu_event};
/// Replace winit's default macOS app menu with one whose Quit (⌘Q) asks the app first.
///
/// winit's Quit ends the process without asking, so unsaved work is lost. This menu's Quit sends
/// the root window a `ViewportCommand::Close` instead: the app's unsaved-changes check runs as it
/// does for the close button, and [`on_close_request`] then quits. Call it once, from the eframe
/// app creator: winit builds its default menu before that and would replace one installed
/// earlier. `app_name` is shown in the menu ("Quit WordCraft").
///
/// For apps without an app menu of their own. It replaces the whole menu and sets muda's
/// process-wide menu-event handler, so an app that builds its own muda menu should not call it:
/// give that menu's Quit item [`QUIT_MENU_ID`] and pass its events to [`on_menu_event`] instead.
/// muda keeps the first handler set, so this and `craft_menu::mac::install` are alternatives: an
/// app calls one of them. `craft_menu::mac::install_app_menu` builds the same menu on craft-menu's
/// menu event handler, which its full menu bar shares.
///
/// On error winit's menu stays: the app still runs, but ⌘Q quits without asking. The app decides
/// whether that is fatal; `?` passes it out of the eframe app creator.
///
/// Other platforms: does nothing and returns `Ok`.
#[cfg(feature = "menu")]
#[deprecated(note = "use craft_menu::mac::install_app_menu")]
pub fn install_app_menu(ctx: &egui::Context, app_name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
#[cfg(target_os = "macos")]
menu::install(ctx, app_name)?;
#[cfg(not(target_os = "macos"))]
let _ = (ctx, app_name);
Ok(())
}
/// 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
}
}