Files
craft-libs/crates/menu/README.md
T
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

18 KiB
Raw Blame History

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 tagged MenuRole::App is 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 the Labeled::Command or Labeled::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)); call craft_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. On Target::Mac, and on Target::Web in 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 Gate forbids (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 on Target::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, one Menu title holds them all. .label(&tr) translates ⋯, Menu and the scroll arrows' names, with the same hook as LayoutOptions::label. The bar fills its row; .shrink_to_fit(max_width) makes it take only the width its titles need, up to max_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 through craft_quit::on_menu_event, which asks the window to close so the unsaved-changes check runs, and doesn't return it; the same goes for Builtin::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:

  1. raw_input, before egui reads the input (eframe::App::raw_input_hook). It uses the gate from the last set_gate.
  2. set_gate, with the app's state for this frame.
  3. 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.
  4. 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.