Files
craft-libs/crates/menu/src/mac.rs
aiongg edc41f68fa craft-menu: shrink-to-fit bar, bar-run Quit, Window list opt-out on macOS; parse the minus sign (#354)
* 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

* craft-menu: shrink-to-fit bar, bar runs Quit and Minimize, mnemonic before an ellipsis documented; craft-keymap: parse the minus sign

* craft-keymap: test the arrow glyphs as keys

* craft-menu: mac::install_with and MacOptions::window_list, for apps that list their documents themselves

* craft-menu: a negative fit limit draws no bar; Window list docs name the Window menu
2026-10-25 23:10:52 -04:00

695 lines
28 KiB
Rust

//! The macOS menu bar, through muda (AppKit menus behind a safe API; no GTK with default features
//! off). This module only builds and updates the `NSMenu`s; the layout and how keys reach egui are
//! in the platform-free part of the crate.
//!
//! - An item's muda id is its command's [`craft_keymap::CommandRef::key`]. muda ids must be
//! unique: a command shown twice (Keyboard Shortcuts in Edit and Window) has `key` for its first
//! item and `key#2` for its second, and both report the command.
//! - A radio item is a check item: AppKit menus have no radio look.
//! - A structure change ([`crate::structure_key`]: a new Open Recent file, a language switch)
//! rebuilds the menu bar; labels, enabled and checked are updated in place. Opening or closing a
//! document ([`crate::window_key`]) rebuilds only the Window menu: AppKit adds its own items to
//! that menu (window tiling, the window list), so muda can't insert rows there by position.
//! - A chosen item is a click or a key equivalent: AppKit's current event says which. A key
//! equivalent goes back to egui as the key press that was made (the event's modifiers, not the
//! item's: AppKit may match ⌘R to a ⇧⌘R item), so the app's key rules still apply. A shortcut
//! the layout assigned ([`crate::Item::layout_shortcut`]) stays a click: the app's keys don't
//! know it. While a text field has the keys, a key equivalent is a click unless the field
//! handles it ([`NativeMenu::set_gate`]).
//! - Only shortcuts with ⌘ or ⌃ become key equivalents. The others (`B`, `⇧B`, `⌥B`, `F5`) are not
//! shown in the native menu: muda 0.21 has no handle on the `NSMenuItem`, and its
//! `set_attributed_title` needs an `NSAttributedString` with a font and a tab stop, which
//! objc2 builds only with `unsafe` calls (the workspace forbids unsafe code).
//! - The process has one muda menu event handler, set by the first [`install`] or
//! [`install_app_menu`]; each install points it at its own menu.
//! - A check or radio item: AppKit (muda) flips its state when it is chosen. The next sync sets it
//! back to the app's state; the driver re-syncs after every menu event.
//! - Hide ([`crate::Builtin::Hide`]) and Hide Others ([`crate::Builtin::HideOthers`]) are custom items calling
//! `NSApplication hide:` and `hideOtherApplications:`, because the predefined ones always take ⌘H
//! and ⌥⌘H.
//! - While a menu is open, changes wait until it closes: replacing or changing an `NSMenu` while
//! AppKit tracks it can leave the displayed menu stranded.
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
use std::time::Duration;
use muda::accelerator::{Accelerator, Code, Modifiers};
use muda::{CheckMenuItem, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSEventModifierFlags, NSEventType};
use craft_keymap::{Chord, CommandRef, Key, Mods};
use crate::driver::{Backend, Dispatch, Event, NativeMenu, native_ok};
use crate::model::{Builtin, Item, ItemKind, Menu, MenuBar, MenuRole, Node, Standard, native_id, structure_key, window_key};
/// Set by [`install`]: there is one full menu bar per process.
static INSTALLED: AtomicBool = AtomicBool::new(false);
/// Where the menu event handler sends events: the menu installed last.
static ROUTE: Mutex<Option<Route>> = Mutex::new(None);
/// muda keeps the first menu event handler set and ignores later ones, so it is set once.
static HANDLER: OnceLock<()> = OnceLock::new();
thread_local! {
/// The minimal app menu ([`install_app_menu`]). muda's items must outlive the native menu
/// that shows them.
static APP_MENU: RefCell<Option<muda::Menu>> = const { RefCell::new(None) };
}
/// Install the native menu bar for `bar` (a [`crate::mac_layout`] result) and return the driver
/// for it. Call it once, on the main thread, in eframe's creator closure, so winit's default menu
/// doesn't stay up. `None` when AppKit refused the menus (the app keeps its in-window menus), off
/// the main thread, or when a menu bar is already installed. It replaces an app menu from
/// [`install_app_menu`].
///
/// craft-menu owns muda's menu event handler. muda keeps the first one set, so an app calls this
/// or the deprecated `craft_quit::install_app_menu`, never both: called first, that one leaves
/// this menu without events; called after, it replaces this menu bar with its app menu.
pub fn install(ctx: &egui::Context, bar: &MenuBar) -> Option<NativeMenu> {
install_with(ctx, bar, MacOptions::default())
}
/// How [`install_with`] sets up the native menus.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct MacOptions {
/// Make the [`MenuRole::Window`] menu AppKit's Windows menu, which lists the app's windows
/// after the menu's own items. On by default. An app that lists its documents itself
/// in the Window menu ([`MenuBar::documents`]) and keeps them in one window turns it off: AppKit
/// would list that window as well.
pub window_list: bool,
}
impl Default for MacOptions {
fn default() -> MacOptions {
MacOptions { window_list: true }
}
}
impl MacOptions {
pub fn window_list(mut self, on: bool) -> MacOptions {
self.window_list = on;
self
}
}
/// [`install`] with `options`.
pub fn install_with(ctx: &egui::Context, bar: &MenuBar, options: MacOptions) -> Option<NativeMenu> {
MainThreadMarker::new()?;
if INSTALLED.swap(true, Ordering::SeqCst) {
return None;
}
let mut menu = MacMenu::new(ctx, options);
if !menu.build(bar) {
INSTALLED.store(false, Ordering::SeqCst);
return None;
}
// `NativeMenu::new` attaches the driver's dispatch, which points the handler at this menu.
Some(NativeMenu::new(Box::new(menu)))
}
/// Replace winit's default macOS app menu with a minimal one whose Quit (⌘Q) asks the app first,
/// for an app without a [`MenuBar`] of its own: About, Services, Hide, Hide Others, Show All and
/// Quit, labelled in English with `app_name` ("Quit WordCraft").
///
/// winit's Quit ends the process without asking, so unsaved work is lost. This Quit has the id
/// [`craft_quit::QUIT_MENU_ID`] and runs [`craft_quit::on_menu_event`]: it asks the root window to
/// close, the app's unsaved-changes check runs as for the close button, and
/// [`craft_quit::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.
///
/// Does nothing when this menu or a full menu bar ([`install`]) is already installed. On error
/// winit's menu stays: the app still runs, but ⌘Q quits without asking.
pub fn install_app_menu(ctx: &egui::Context, app_name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if MainThreadMarker::new().is_none() {
return Err("craft_menu::mac::install_app_menu runs on the main thread".into());
}
if INSTALLED.load(Ordering::SeqCst) || APP_MENU.with_borrow(Option::is_some) {
return Ok(());
}
let quit = MenuItem::with_id(craft_quit::QUIT_MENU_ID, format!("Quit {app_name}"), true, Some(Accelerator::new(Modifiers::META, Code::KeyQ)));
let app_menu = Submenu::new(app_name, true);
app_menu.append_items(&[
&PredefinedMenuItem::about(Some(&format!("About {app_name}")), None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(Some(&format!("Hide {app_name}"))),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&quit,
])?;
let menu = muda::Menu::new();
menu.append(&app_menu)?;
menu.init_for_nsapp();
route_events(Route::AppMenu(ctx.clone()));
APP_MENU.set(Some(menu));
Ok(())
}
/// The installed menu the handler reports to.
#[derive(Clone)]
enum Route {
/// [`install_app_menu`]'s: only Quit reports, to craft-quit.
AppMenu(egui::Context),
/// A full menu bar's.
Menu(MenuRoute),
}
#[derive(Clone)]
struct MenuRoute {
ctx: egui::Context,
targets: Targets,
dispatch: Dispatch,
tx: Sender<Event>,
}
/// What each native item runs, by muda id, for the handler.
type Targets = Arc<Mutex<HashMap<String, Target>>>;
/// Point the process's menu event handler at `route`, setting the handler the first time.
fn route_events(route: Route) {
*lock(&ROUTE) = Some(route);
HANDLER.get_or_init(|| MenuEvent::set_event_handler(Some(handle_event)));
}
/// The menu event handler. AppKit calls it on the main thread, from the event that chose the
/// item: a key equivalent is a key-down, a click a mouse-up (or ↩ inside the open menu).
fn handle_event(e: MenuEvent) {
let held = MainThreadMarker::new()
.and_then(|mtm| NSApplication::sharedApplication(mtm).currentEvent())
.filter(|ev| ev.r#type() == NSEventType::KeyDown)
.map(|ev| {
let flags = ev.modifierFlags();
Mods {
cmd: flags.contains(NSEventModifierFlags::Command),
ctrl: flags.contains(NSEventModifierFlags::Control),
alt: flags.contains(NSEventModifierFlags::Option),
shift: flags.contains(NSEventModifierFlags::Shift),
}
});
// A copy, so nothing is locked while the app's hook runs.
let route = lock(&ROUTE).clone();
let ctx = match route {
None => return,
Some(Route::AppMenu(ctx)) => {
craft_quit::on_menu_event(&ctx, e.id.as_ref());
ctx
}
Some(Route::Menu(r)) => {
let target = lock(&r.targets).get(e.id.as_ref()).cloned();
// Ids that aren't ours are AppKit's own items (Services, Zoom, …), already handled.
if let Some(t) = target {
let event = r.dispatch.chosen(&t.command, t.chord.as_ref(), t.layout_shortcut, held);
// The receiver goes away only with the app.
let _ = r.tx.send(event);
}
r.ctx
}
};
// Choosing an item is not window input, so nothing else wakes egui: without this the item
// runs only with the next input event, 8 to 90 seconds later in measurements.
ctx.request_repaint();
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
#[derive(Clone)]
enum Handle {
Plain(MenuItem),
Check(CheckMenuItem),
}
/// One native item.
#[derive(Clone)]
struct Entry {
handle: Handle,
target: Target,
label: String,
enabled: bool,
}
/// What choosing an item reports.
#[derive(Clone)]
struct Target {
/// The command (several items can share one).
command: CommandRef,
chord: Option<Chord>,
layout_shortcut: bool,
}
/// The native items by muda id.
type Entries = HashMap<String, Entry>;
/// The Window menu, the muda ids of its items, and its [`window_key`].
type WindowMenu = (Submenu, Vec<String>, u64);
struct MacMenu {
ctx: egui::Context,
menu: Option<muda::Menu>,
entries: Entries,
/// The entries' targets, for the menu event handler.
targets: Targets,
tx: Sender<Event>,
rx: Receiver<Event>,
structure: u64,
window: Option<WindowMenu>,
/// The latest menus, waiting for the open menu to close.
pending: Option<MenuBar>,
options: MacOptions,
}
impl MacMenu {
fn new(ctx: &egui::Context, options: MacOptions) -> MacMenu {
let (tx, rx) = channel();
MacMenu {
ctx: ctx.clone(),
menu: None,
entries: HashMap::new(),
targets: Targets::default(),
tx,
rx,
structure: 0,
window: None,
pending: None,
options,
}
}
/// Hand the entries' targets to the menu event handler.
fn publish(&self) {
*lock(&self.targets) = self.entries.iter().map(|(id, e)| (id.clone(), e.target.clone())).collect();
}
/// Build the menu bar for `bar` and make it NSApp's. On an error or a panic in muda or AppKit
/// the menus stay as they were (never crash for a menu) and this returns false.
fn build(&mut self, bar: &MenuBar) -> bool {
let built = catch_unwind(AssertUnwindSafe(|| -> muda::Result<(muda::Menu, Entries, Option<WindowMenu>)> {
let mut entries = Entries::new();
let mut window = None;
let menu = muda::Menu::new();
let mut special = Vec::new();
for m in &bar.menus {
let (sub, ids) = submenu(&mut entries, m)?;
menu.append(&sub)?;
if m.role == MenuRole::Window {
window = Some((sub.clone(), ids, window_key(bar)));
}
if m.role == MenuRole::Help || (m.role == MenuRole::Window && self.options.window_list) {
special.push((m.role, sub));
}
}
menu.init_for_nsapp();
// Only once the menu is NSApp's: muda looks the submenus up in the installed main menu.
for (role, sub) in special {
match role {
MenuRole::Window => sub.set_as_windows_menu_for_nsapp(),
_ => sub.set_as_help_menu_for_nsapp(),
}
}
Ok((menu, entries, window))
}));
match built {
Ok(Ok((menu, entries, window))) => {
self.menu = Some(menu);
self.entries = entries;
self.window = window;
self.structure = structure_key(bar);
self.publish();
true
}
_ => {
// The new menu may have been made NSApp's before the failure.
if let Some(old) = &self.menu {
let _ = catch_unwind(AssertUnwindSafe(|| {
old.init_for_nsapp();
if let Some((sub, ..)) = &self.window
&& self.options.window_list
{
sub.set_as_windows_menu_for_nsapp();
}
}));
}
false
}
}
}
/// Replace the Window menu (a document opened or closed), leaving the other menus as they are.
/// Falls back to a full build when that fails.
fn rebuild_window(&mut self, bar: &MenuBar) {
let (Some(menu), Some((old, old_ids, _))) = (self.menu.clone(), self.window.clone()) else {
self.build(bar);
return;
};
let Some((at, spec)) = bar.menus.iter().enumerate().find(|(_, m)| m.role == MenuRole::Window) else {
self.build(bar);
return;
};
let mut entries = self.entries.clone();
for id in &old_ids {
entries.remove(id);
}
let replaced = catch_unwind(AssertUnwindSafe(|| -> muda::Result<WindowMenu> {
let (sub, ids) = submenu(&mut entries, spec)?;
menu.remove(&old)?;
if let Err(e) = menu.insert(&sub, at) {
let _ = menu.insert(&old, at);
return Err(e);
}
if self.options.window_list {
sub.set_as_windows_menu_for_nsapp();
}
Ok((sub, ids, window_key(bar)))
}));
match replaced {
Ok(Ok(window)) => {
self.entries = entries;
self.window = Some(window);
self.publish();
}
_ => {
self.build(bar);
}
}
}
fn update(&mut self, bar: &MenuBar) {
if self.menu.is_none() || structure_key(bar) != self.structure {
self.build(bar);
return;
}
if self.window.as_ref().map(|w| w.2) != Some(window_key(bar)) {
self.rebuild_window(bar);
}
// Items by muda id: the n-th item of a command, depth first, is its n-th native item.
let mut items: HashMap<String, &Item> = HashMap::new();
for it in bar.items() {
let id = native_id(&it.command, |id| items.contains_key(id));
items.insert(id, it);
}
for (id, e) in &mut self.entries {
let Some(it) = items.get(id) else { continue };
if e.label != it.label {
let text = escape(&it.label);
match &e.handle {
Handle::Plain(h) => h.set_text(text),
Handle::Check(h) => h.set_text(text),
}
e.label = it.label.clone();
}
if e.enabled != it.enabled {
match &e.handle {
Handle::Plain(h) => h.set_enabled(it.enabled),
Handle::Check(h) => h.set_enabled(it.enabled),
}
e.enabled = it.enabled;
}
// Compare with AppKit's own state: muda flips a check item when it's chosen, and the
// app's state wins (a radio item chosen again stays selected).
if let (Handle::Check(h), Some(c)) = (&e.handle, it.kind.checked())
&& h.is_checked() != c
{
h.set_checked(c);
}
}
}
/// Nothing else may wake egui when the menu closes (Escape goes to the menu), so look again.
fn retry_later(&mut self, bar: MenuBar) {
self.pending = Some(bar);
self.ctx.request_repaint_after(Duration::from_millis(250));
}
}
/// A top-level menu, and the muda ids of the items it added to `entries`.
fn submenu(entries: &mut Entries, m: &Menu) -> muda::Result<(Submenu, Vec<String>)> {
let before: HashSet<String> = entries.keys().cloned().collect();
let sub = Submenu::new(escape(&m.title), true);
append(entries, &sub, &m.children)?;
let ids = entries.keys().filter(|id| !before.contains(*id)).cloned().collect();
Ok((sub, ids))
}
fn append(entries: &mut Entries, sub: &Submenu, nodes: &[Node]) -> muda::Result<()> {
for n in nodes {
match n {
Node::Separator => sub.append(&PredefinedMenuItem::separator())?,
Node::Submenu { label, children, .. } => {
let s = Submenu::new(escape(label), !children.is_empty());
append(entries, &s, children)?;
sub.append(&s)?;
}
Node::Standard { kind, label } => {
let text = Some(label.as_str());
let p = match kind {
Standard::Services => PredefinedMenuItem::services(text),
Standard::ShowAll => PredefinedMenuItem::show_all(text),
Standard::Minimize => PredefinedMenuItem::minimize(text),
Standard::Zoom => PredefinedMenuItem::maximize(text),
Standard::BringAllToFront => PredefinedMenuItem::bring_all_to_front(text),
Standard::CloseWindow => PredefinedMenuItem::close_window(text),
};
sub.append(&p)?;
}
Node::Item(it) => {
let id = native_id(&it.command, |id| entries.contains_key(id));
let chord = it.shortcut;
let accel = chord.as_ref().filter(|c| native_ok(c)).and_then(accelerator);
let handle = match it.kind {
ItemKind::Check(c) | ItemKind::Radio(c) => {
let h = CheckMenuItem::with_id(id.clone(), escape(&it.label), it.enabled, c, accel);
sub.append(&h)?;
Handle::Check(h)
}
ItemKind::Plain => {
let h = MenuItem::with_id(id.clone(), escape(&it.label), it.enabled, accel);
sub.append(&h)?;
Handle::Plain(h)
}
};
let target = Target { command: it.command.clone(), chord, layout_shortcut: it.layout_shortcut };
let entry = Entry { handle, target, label: it.label.clone(), enabled: it.enabled };
entries.insert(id, entry);
}
}
}
Ok(())
}
impl Backend for MacMenu {
fn sync(&mut self, bar: &MenuBar) {
if self.tracking() {
// The driver counts this sync as done and may not send these menus again.
self.retry_later(bar.clone());
return;
}
self.pending = None;
self.update(bar);
}
fn drain(&mut self) -> Vec<Event> {
let mut out = Vec::new();
while let Ok(event) = self.rx.try_recv() {
// With asynchronous menu presentation the menu can stay on screen after its item ran.
self.cancel_tracking();
// Hide and Hide Others run here. While a shortcut is recorded their key equivalents
// arrive as key presses instead, for the recorder.
if let Event::Click(command) = &event
&& let Some(hide @ (Builtin::Hide | Builtin::HideOthers)) = Builtin::of(command)
{
if let Some(mtm) = MainThreadMarker::new() {
let app = NSApplication::sharedApplication(mtm);
if hide == Builtin::Hide { app.hide(None) } else { app.hideOtherApplications(None) }
}
continue;
}
out.push(event);
}
// After the events: they were chosen from the menus as they were.
if let Some(bar) = self.pending.take() {
if self.tracking() {
self.retry_later(bar);
} else {
self.update(&bar);
}
}
out
}
/// A menu bar menu is open while one of its titles is highlighted.
///
/// This assumes AppKit sets the main menu's `highlightedItem` while one of its menus is open
/// and clears it when the menu closes; not yet confirmed on every macOS version. If it stayed
/// set, menu updates would wait (retried every 250 ms) and the driver would keep only one
/// window click from egui ([`NativeMenu::raw_input`]).
fn tracking(&self) -> bool {
MainThreadMarker::new().and_then(|mtm| NSApplication::sharedApplication(mtm).mainMenu()).is_some_and(|m| m.highlightedItem().is_some())
}
fn attach(&mut self, dispatch: Dispatch) {
route_events(Route::Menu(MenuRoute { ctx: self.ctx.clone(), targets: self.targets.clone(), dispatch, tx: self.tx.clone() }));
}
fn cancel_tracking(&mut self) {
if let Some(menu) = MainThreadMarker::new().and_then(|mtm| NSApplication::sharedApplication(mtm).mainMenu()) {
menu.cancelTracking();
}
}
}
/// `&` marks a mnemonic in muda labels.
fn escape(s: &str) -> String {
s.replace('&', "&&")
}
/// A key equivalent for AppKit. `cmd` is ⌘ (`META`) and `ctrl` is ⌃: never folded together.
fn accelerator(c: &Chord) -> Option<Accelerator> {
let m = c.mods;
let mut mods = Modifiers::empty();
for (on, flag) in [(m.cmd, Modifiers::META), (m.ctrl, Modifiers::CONTROL), (m.alt, Modifiers::ALT), (m.shift, Modifiers::SHIFT)] {
if on {
mods |= flag;
}
}
let code = match c.key {
Key::A => Code::KeyA,
Key::B => Code::KeyB,
Key::C => Code::KeyC,
Key::D => Code::KeyD,
Key::E => Code::KeyE,
Key::F => Code::KeyF,
Key::G => Code::KeyG,
Key::H => Code::KeyH,
Key::I => Code::KeyI,
Key::J => Code::KeyJ,
Key::K => Code::KeyK,
Key::L => Code::KeyL,
Key::M => Code::KeyM,
Key::N => Code::KeyN,
Key::O => Code::KeyO,
Key::P => Code::KeyP,
Key::Q => Code::KeyQ,
Key::R => Code::KeyR,
Key::S => Code::KeyS,
Key::T => Code::KeyT,
Key::U => Code::KeyU,
Key::V => Code::KeyV,
Key::W => Code::KeyW,
Key::X => Code::KeyX,
Key::Y => Code::KeyY,
Key::Z => Code::KeyZ,
Key::Num0 => Code::Digit0,
Key::Num1 => Code::Digit1,
Key::Num2 => Code::Digit2,
Key::Num3 => Code::Digit3,
Key::Num4 => Code::Digit4,
Key::Num5 => Code::Digit5,
Key::Num6 => Code::Digit6,
Key::Num7 => Code::Digit7,
Key::Num8 => Code::Digit8,
Key::Num9 => Code::Digit9,
Key::F1 => Code::F1,
Key::F2 => Code::F2,
Key::F3 => Code::F3,
Key::F4 => Code::F4,
Key::F5 => Code::F5,
Key::F6 => Code::F6,
Key::F7 => Code::F7,
Key::F8 => Code::F8,
Key::F9 => Code::F9,
Key::F10 => Code::F10,
Key::F11 => Code::F11,
Key::F12 => Code::F12,
Key::F13 => Code::F13,
Key::F14 => Code::F14,
Key::F15 => Code::F15,
Key::F16 => Code::F16,
Key::F17 => Code::F17,
Key::F18 => Code::F18,
Key::F19 => Code::F19,
Key::F20 => Code::F20,
Key::F21 => Code::F21,
Key::F22 => Code::F22,
Key::F23 => Code::F23,
Key::F24 => Code::F24,
Key::OpenBracket => Code::BracketLeft,
Key::CloseBracket => Code::BracketRight,
Key::Backslash => Code::Backslash,
Key::Slash => Code::Slash,
// `+` is ⇧= on the keyboards AppKit matches by key: Zoom In shows ⌘=.
Key::Equals | Key::Plus => Code::Equal,
Key::Minus => Code::Minus,
Key::Quote => Code::Quote,
Key::Semicolon => Code::Semicolon,
Key::Comma => Code::Comma,
Key::Period => Code::Period,
Key::Backtick => Code::Backquote,
// `Delete` is forward delete (egui's `Key::Delete`), `Backspace` the key Macs label
// delete. muda writes them as U+007F and U+0008; which physical key AppKit matches to
// each is not yet checked on a Mac.
Key::Delete => Code::Delete,
Key::Backspace => Code::Backspace,
Key::Insert => Code::Insert,
Key::Left => Code::ArrowLeft,
Key::Right => Code::ArrowRight,
Key::Up => Code::ArrowUp,
Key::Down => Code::ArrowDown,
Key::Home => Code::Home,
Key::End => Code::End,
Key::PageUp => Code::PageUp,
Key::PageDown => Code::PageDown,
Key::Escape if !mods.is_empty() => Code::Escape,
Key::Enter if !mods.is_empty() => Code::Enter,
Key::Tab if !mods.is_empty() => Code::Tab,
Key::Space if !mods.is_empty() => Code::Space,
_ => return None,
};
Some(Accelerator::new(mods, code))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::driver::key_event;
/// The shortcuts the layout writes itself, and common app ones, become key equivalents.
#[test]
fn shortcuts_map_to_key_equivalents() {
#[rustfmt::skip]
let shortcuts = [
"Cmd+Q", "Cmd+H", "Cmd+Alt+H", "Ctrl+Cmd+H", "Ctrl+Cmd+M", "Cmd+,", "Cmd+Shift+Z", "Cmd+1", "Cmd+[", "Cmd+=", "Cmd++",
"Cmd+-", "Cmd+Delete", "Cmd+Backspace", "Cmd+Left", "Alt+Cmd+ArrowDown", "Cmd+Escape", "Cmd+Home", "Cmd+End",
"Cmd+PageUp", "Cmd+PageDown", "Cmd+Enter", "Ctrl+F7",
];
for s in shortcuts {
let c = Chord::parse(s).unwrap();
assert!(native_ok(&c) && accelerator(&c).is_some(), "{s} has no key equivalent");
assert!(matches!(key_event(&c, true), egui::Event::Key { .. }), "{s} has no egui key press");
}
let code = |s: &str| accelerator(&Chord::parse(s).unwrap()).unwrap();
assert_eq!(code("Cmd+Delete"), Accelerator::new(Modifiers::META, Code::Delete), "forward delete, as egui's Key::Delete");
assert_eq!(code("Cmd+Backspace"), Accelerator::new(Modifiers::META, Code::Backspace));
assert_eq!(code("Cmd++"), code("Cmd+="), "⌘+ is the ⌘= key");
}
#[test]
fn control_and_command_stay_distinct() {
let a = accelerator(&Chord::parse("Cmd+M").unwrap()).unwrap();
let b = accelerator(&Chord::parse("Ctrl+Cmd+M").unwrap()).unwrap();
assert_ne!(a, b);
}
}