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

281 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](../keymap/src/lib.rs) (commands and chords) and [`craft-quit`](../quit/src/lib.rs),
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:
```rust
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:
```rust
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.
```rust
#[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.
```rust
#[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.
```rust
// 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.