* 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
craft-menu
A Craft app's menus, laid out for macOS, Windows, Linux and the web. The app supplies its menu
tree and runs its commands; this crate lays the menus out the way each platform's apps do, takes
shortcuts from craft-keymap, drives a platform Backend, and turns the menu's key
equivalents back into the input egui would have had without it. It depends on egui,
craft-keymap (commands and chords) and craft-quit,
and on no app. Everything but the macOS backend
(craft_menu::mac, built on muda) is platform-free and also builds for wasm32. The layout and driver come from PhotoCraft's native_menu.rs, the egui bar from its menu_nav.rs and in-window menu bar.
The menus
Build a MenuBar directly, or from the flat rows an in-window bar draws (from_items, with
Row { path, command, label, shortcut, enabled, kind }, --- for a separator). Give each menu
its role (MenuRole::{App, File, Edit, View, Window, Help, Other}), then tag what macOS puts in
fixed places:
let mut bar = craft_menu::from_items(&["File", "Edit", "View", "Window", "Help"], &rows);
bar.tag_menu("File", MenuRole::File);
bar.tag_menu("Edit", MenuRole::Edit);
bar.tag_menu("View", MenuRole::View);
bar.tag_menu("Window", MenuRole::Window);
bar.tag_menu("Help", MenuRole::Help);
bar.tag_item(&"help.about".into(), ItemRole::About);
bar.tag_item(&"file.exit".into(), ItemRole::Quit); // the app's quit, with its unsaved-changes prompt
bar.tag_submenu(MenuRole::Edit, "Preferences", ItemRole::Settings);
bar.tag_submenu(MenuRole::View, "Theme", ItemRole::Appearance);
Each item runs a craft_keymap::CommandRef, and a click reports it. A command that takes a
parameter carries it in the CommandRef:
CommandRef::with_params("file.open_recent", json!({"index": 3})). Items built directly use
Item::new(command, label) with .shortcut(chord), .enabled(..), .check(..) or
.radio(..) (the item's ItemKind), .role(..) and .platforms(..); submenus use
Node::submenu(label, children), with .role(..) for a tagged one. A shortcut is a
craft_keymap::Chord: cmd is ⌘ and ctrl the Control key. macOS shows radio items as check
items. The app's open documents go in MenuBar::documents (Document::new(command, name),
.active(..) for the one in front, hidden for one kept open but not listed), and the layouts
list them in the Window menu. The items the
layout adds itself run a Builtin command (Hide, Hide Others, Minimize, Quit), which the crate
handles.
Layouts
layout(&bar, target, &LayoutOptions::new("PhotoCraft")) lays the menus out for a Target
(Mac, Windows, Linux, Web; Target::current() for this build) and returns the bar and
the clashes it resolved. Every layout first drops the items and submenus whose .platforms(..)
leave the target out, and lists the open documents (but the hidden ones) last in the Window menu
as radio items, the one in front selected. A submenu left empty goes; for an empty dynamic list
(no recent files) the app supplies a disabled placeholder item, which keeps the submenu.
| Target | About | Settings | Quit | Also |
|---|---|---|---|---|
Mac |
app menu | app menu, Settings… (⌘,) | app menu, ⌘Q | Services, Hide, Minimize, Zoom, Close Window |
Windows |
last in Help | last in Edit, Preferences… | last in File, Exit | |
Linux |
last in Help | last in Edit, Preferences… | last in File, Quit | |
Web |
last in Help | last in Edit, Preferences… | none | browser shortcuts removed |
The layouts find these items by role, never by title, and add a File, Edit, Window or Help menu
when the app has none to put them in. Language and Appearance stay where the app put them off
macOS, and the Windows, Linux and web layouts keep the app's shortcuts. The browser keeps Ctrl or
⌘ with W, N, T and Q, and with Shift+N, Shift+T and Shift+W, for its tabs and windows, so the
web layout takes those shortcuts off the items and reports each as a Clash.
mac_layout(&bar, &LayoutOptions::new("PhotoCraft")) returns the Mac menu bar and the clashes
it resolved:
- App menu: About, Settings (⌘,), Language, Appearance, Services, Hide (⌘H), Hide Others
(⌥⌘H), Show All, Quit (⌘Q). The tagged items leave their old menus. Without a tagged Quit item
the layout adds one with
craft_quit::QUIT_MENU_ID. A menu the app taggedMenuRole::Appis merged in: its other items follow Appearance. - Window: Minimize, Zoom, the app's own Window items, Bring All to Front, then the open documents. A Window menu is added if the app has none. File gets Close Window (⌘W).
- Clashes: when the app binds ⌘H, ⌥⌘H, ⌘M, ⌘W, ⌘, or ⌘Q to a command, the command keeps
it. Hide and Minimize move to ⌃⌘H and ⌃⌘M, the others go without a shortcut, and each case is
reported as a
Clash. Shortcuts bound outside the menus (tool and canvas keys) count when the app lists them:LayoutOptions::new("PhotoCraft").extra_shortcuts(&[(chord, command), ..]).
The labels the layouts write (About {app}, Preferences…, Exit, Window, Minimize, …)
are English. To translate them, pass LayoutOptions { label, ..LayoutOptions::new(app_name) },
where label maps an English label such as "Quit {app}" to the app's translation; the crate
substitutes {app}.
Keymap
apply_keymap(&mut bar, &keymap) gives each item the shortcut its command has in the app's
craft_keymap::Keymap, the user's overrides included: the first single-press binding, one in
the command's home scope first. A command the keymap knows but binds to nothing loses its
shortcut; an item whose command the keymap doesn't know keeps its own. Items of toggle commands
(Bindable::toggle) become check items, keeping their checked state; radio items stay radio
items.
Each frame, apply_state(&mut bar, |command| ItemState { enabled, checked }) sets enabled and
checked from the app's state. checked: Some(..) makes a plain item a check item and selects or
clears a radio item; None leaves the kind as it is. The built-in items and the open documents
are left alone. Both run on the app's bar, before the layout.
Windows, Linux and web: the egui bar
MenuBarUi draws a laid-out bar with egui, in a top panel, and returns the commands chosen that
frame:
let laid_out = craft_menu::layout(&menu_bar, Target::current(), &LayoutOptions::new("PhotoCraft")).bar;
egui::Panel::top("menu-bar").show(ui, |ui| {
let gate = Gate { modal: dialog_open, recording_shortcut, text_editing: ui.ctx().text_edit_focused() };
for command in MenuBarUi::new(&laid_out).gate(gate).label(&tr).show(ui) {
app.run(&command);
}
});
- Mouse: a press on a title opens its menu and releasing over an item runs it; a click on the title again, Esc or a click outside closes it. While a menu is open, moving onto another title opens that one. Submenus open on hover and stay open while the pointer heads for them across other rows. A menu taller than the window scrolls, with the wheel or by resting on the ▲ and ▼ arrows.
- Keys, while a menu is open: ↑ and ↓ move the highlight, → opens a submenu or the next
menu, ← closes the submenu or opens the previous menu, ↩ and Space run the item, Esc closes.
craft_menu::bar::is_open(ctx)tells the app's shortcut handling to wait meanwhile. - Keyboard access (Windows, Linux, web): a tap on Alt alone, or F10, puts the keyboard on
the bar: the first title is lit, ← and → move along the titles, ↓, ↩ and Space open the lit
menu, Esc, a second tap or a click leaves. Alt+letter opens the menu with that mnemonic, and
in an open menu a letter runs the row with that mnemonic or opens its submenu. Mnemonics are
underlined while Alt is held, while the bar has the keyboard, and in a menu opened from the
keyboard. Each menu level assigns them from the labels as shown, so translated: unique within
the level, the first unused letter, word starts first;
.mnemonics(&[("View", 'W'), ("file.open", 'P')])names preferred letters by title as shown or by command id. A label without an ASCII letter or digit shows its English label's letter after it, "文件(F)", before a trailing ellipsis ("開く(O)…"), when.english(&hook)returns that English label for theLabeled::CommandorLabeled::Title; without one it has no mnemonic. While the bar has the keyboard the app's shortcuts wait, as for an open menu (craft_menu::bar::is_focused(ctx)); callcraft_menu::bar::leave_for_shortcut(ctx)before them so that a command shortcut (Ctrl+S, F7) leaves the bar and runs. While a dialog is open or a shortcut is being recorded (Gate), Alt, F10 and the letters leave the bar alone. While a text field has the keys, a tap on Alt still focuses the bar, as on Windows, but a letter without Alt goes on to the field (and takes the keyboard off the bar) unless a menu is open. OnTarget::Mac, and onTarget::Webin a browser on a Mac (ctx.os()), the bar has no mnemonics and Alt does nothing: Option is a typing key there. - Items: disabled items, and the items the
Gateforbids (Gate::allows), are greyed and never run. Check items show ✓, the selected radio item a dot, and the shortcut column shows every shortcut, the display-only ones too, in the target's names (⌘ glyphs onTarget::Mac, Ctrl names on Windows and Linux, the browser's system's names on the web;.target(..)to choose). - Narrow windows: the menus that don't fit move into a trailing
⋯menu; when not even the first fits, oneMenutitle holds them all..label(&tr)translates⋯,Menuand the scroll arrows' names, with the same hook asLayoutOptions::label. The bar fills its row;.shrink_to_fit(max_width)makes it take only the width its titles need, up tomax_width, so other controls can share the row, and the menus past that overflow. - Quit: without an app Quit item, the Windows and Linux layouts add
Builtin::Quit(craft_quit::QUIT_MENU_ID). The bar runs it itself throughcraft_quit::on_menu_event, which asks the window to close so the unsaved-changes check runs, and doesn't return it; the same goes forBuiltin::Minimize. - Accessibility: AccessKit sees a menu bar of menu items, the open menus, check and radio
items with their state, disabled items, and each item's shortcut and mnemonic (
access_key). - Style:
BarStyle::from_style(ui.style())by default;.style(..)sets title padding and color, the menu width, separator room and the check and radio marks.
macOS
Install the menu bar once, on the main thread, in eframe's creator closure (so winit's default
menu doesn't stay up). None means AppKit refused the menus: keep the in-window ones.
#[cfg(target_os = "macos")]
let native_menu = craft_menu::mac::install(&cc.egui_ctx, &craft_menu::mac_layout(&menu_bar, &LayoutOptions::new("PhotoCraft")).bar);
An app without a MenuBar yet installs the minimal app menu instead: About, Services, Hide,
Hide Others, Show All and Quit, where Quit asks the app first.
#[cfg(target_os = "macos")]
craft_menu::mac::install_app_menu(&cc.egui_ctx, "WordCraft")?;
craft-menu owns muda's menu event handler. muda keeps the first one set per process, so the
crate sets it once and both mac::install and mac::install_app_menu use it; a later
mac::install replaces the minimal app menu, and a second mac::install returns None.
craft_quit::install_app_menu is deprecated in favor of mac::install_app_menu: it sets its own
handler, so an app calls it or craft-menu's installers, never both. Called first, it leaves
craft-menu's menus without events; called after, it replaces their menu bar. Quit is
never AppKit's predefined Quit: it is the app's item tagged ItemRole::Quit, or the item with
craft_quit::QUIT_MENU_ID, which runs craft_quit::on_menu_event (the window is asked to close,
so the app's unsaved-changes prompt runs). Each menu event wakes egui, so a chosen item runs at
once rather than with the next input event.
The backend calls AppKit's Window and Help menu hooks for the menus with MenuRole::Window and
MenuRole::Help; it never looks at titles. Services is AppKit's own submenu. AppKit's Window
menu lists the app's windows after the menu's own items. An app that keeps its documents in one
window and lists them in the Window menu itself (MenuBar::documents) would see each twice, so it
installs with
mac::install_with(ctx, &bar, MacOptions::default().window_list(false)).
Only shortcuts with ⌘ or ⌃ become key equivalents. The others (B, ⇧B, ⌥B, a bare F5) are
tool keys the app handles itself; as key equivalents AppKit would take them from text fields.
The native menu does not show them: muda 0.21 gives no access to the NSMenuItem, and its
set_attributed_title needs an NSAttributedString with a font and a right tab stop, which
objc2 builds only through unsafe calls, and the workspace forbids unsafe code. The egui bar
shows them in its shortcut column.
Check and radio items are AppKit check items. AppKit flips one when it is chosen; the next sync sets it back to the app's state, so the app's state always wins.
While a menu is open, a click or Escape in the window closes it and does not reach egui (no canvas gesture, no tool action); pointer moves pass through. Only one click is kept this way until the menu reports closed, so a menu wrongly reported open costs one click. Menu updates wait until the menu closes. A rebuild that fails keeps the menus as they were.
Per frame
Key equivalents stay with egui: the backend reports them as Event::Key, and raw_input feeds
each one to egui as egui-winit would have, so the app's own key handling (text fields, dialogs,
tool-specific undo) runs as without a native menu. ⌘C, ⌘X and ⌘V become egui's Copy, Cut and
Paste (ClipboardKeys::Egui, the default), so text fields copy and paste; an app that runs its
clipboard commands from the keys itself sets menu.clipboard_keys(ClipboardKeys::Raw) to get
key presses. A shortcut the layout assigned (⌘Q on the app's Quit item, ⌘, on Settings) comes
back as a click instead, since the app's key handling doesn't bind it.
The app tells the menu what may run with a Gate, every frame (set_gate):
| Gate field | Effect on the native menu |
|---|---|
modal |
The app's items are greyed (updated in place) and their clicks dropped, also clicks that arrived before the dialog opened. Hide, Hide Others, Minimize and Quit stay, and so do the items whose shortcut a text field handles (Undo, Copy, Select All): AppKit doesn't pass a disabled item's key equivalent on, so ⌘C and ⌘Z would die in the dialog's fields. |
recording_shortcut |
As modal, and every key equivalent reaches egui as the plain key press (⌘Q, ⌘H, ⌘C included), so the recorder sees it. |
text_editing |
A key equivalent comes back as a click (⌘S saves while typing), except the keys the field handles itself: ⌘C, ⌘X and ⌘V (as ClipboardKeys says) and craft_keymap::text_editing_chords (⌘A, ⌘Z, ⇧⌘Z, ⌘←, …). A mouse click on one of those items (Edit › Copy, Undo) is a press and release of its shortcut, so it acts on the field. |
Each frame, in this order:
raw_input, before egui reads the input (eframe::App::raw_input_hook). It uses the gate from the lastset_gate.set_gate, with the app's state for this frame.take_clicks, and run the commands it returns: the clicks since the last frame that the gate allows now. Minimize and Quit are already handled.sync_with, which lays the menus out again only when the app's state changed or a menu event arrived, and re-syncs them in place when the gate changed.
// eframe::App::raw_input_hook, before egui reads the input:
if let Some(menu) = &mut self.native_menu {
menu.raw_input(raw_input);
}
// In the frame:
if let Some(menu) = &mut self.native_menu {
menu.set_gate(Gate {
modal: self.dialog.is_some(),
recording_shortcut: self.shortcut_editor.is_recording(),
text_editing: ctx.wants_keyboard_input(),
});
for command in menu.take_clicks(ctx) {
self.run_command(ctx, &command);
}
let state = self.menu_state_hash(craft_menu::had_input(ctx).then_some(ctx.cumulative_frame_nr()));
for c in menu.sync_with(state, || craft_menu::mac_layout(&self.menu_bar(), &LayoutOptions::new("PhotoCraft"))) {
log::info!("menu: {} is {} on macOS and {} in the app; {}", c.shortcut, c.system, c.command, c.resolution);
}
}
sync_with builds the layout only when state changed or a menu event arrived, because
building the rows usually walks every command's enabled state. The backend then updates labels,
enabled and checked in place and rebuilds only when structure_key (or, for the open
documents, window_key) changes. Adding or removing an item in a dynamic list (recent files,
workspaces, plugins) changes structure_key; relabelling one doesn't. An item that was a check
item once stays one, unchecked, when the app's state stops giving it a checked state, so that
doesn't rebuild the menus.
state must cover everything the menus show. A change that comes without input, such as a load
or save finishing on a worker or a timer, leaves the menus stale until the next input or menu
event unless it is in the hash. An app that can't keep the hash complete calls
menu.sync(layout) instead, with the layout built every frame: it is compared with the last
one and synced only when it differs.
An app that needs a click while egui is idle (to wake its own loop, or hand the command to a
worker) sets menu.on_command(Box::new(|command| ..)). The hook runs from the menu event
handler as the click arrives, on the main thread, for the app commands the gate allows; key
equivalents reported as key presses and the built-in items don't reach it. It returns true
when it ran the command; take_clicks leaves those out and returns the others.