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
This commit is contained in:
aiongg
2026-10-25 23:10:52 -04:00
committed by GitHub
parent ee4103fe78
commit edc41f68fa
6 changed files with 165 additions and 25 deletions
+3 -1
View File
@@ -126,12 +126,14 @@ const KEY_ALIASES: &[(&str, Key)] = &[
("grave", Key::Backtick),
("backquote", Key::Backtick),
("apostrophe", Key::Quote),
// The minus sign menus and docs print (U+2212), not the hyphen-minus the key types.
("−", Key::Minus),
];
impl Key {
/// Reads a key name: canonical (`K`, `PageUp`, `;`), egui variant names (`ArrowUp`, `Num5`,
/// `Semicolon`) and the aliases `esc`, `return`, `del`, `pgup`, `pgdn`, `spacebar`, `ins`,
/// and the Mac key glyphs. Case does not matter.
/// the Mac key glyphs and the minus sign `−`. Case does not matter.
pub fn parse(name: &str) -> Option<Key> {
Key::ALL
.iter()
+18
View File
@@ -27,6 +27,22 @@ fn canonical_text_round_trips_for_every_key_and_modifier_set() {
assert_eq!(serde_json::to_string(&chord("shift+cmd+k")).unwrap(), "\"Cmd+Shift+K\"");
}
#[test]
fn plus_and_minus_keys_parse_from_their_glyphs() {
assert_eq!(Key::parse("+"), Some(Key::Plus));
assert_eq!(Key::parse("-"), Some(Key::Minus));
assert_eq!(Key::parse("−"), Some(Key::Minus), "U+2212");
}
#[test]
fn arrow_glyphs_parse_as_arrow_keys() {
for (glyph, key) in [("←", Key::Left), ("→", Key::Right), ("↑", Key::Up), ("↓", Key::Down)] {
assert_eq!(Key::parse(glyph), Some(key), "{glyph}");
}
assert_eq!(Chord::parse("⌘←").unwrap().to_string(), "Cmd+Left");
assert_eq!(Chord::parse("Alt+Shift+→").unwrap().to_string(), "Alt+Shift+Right");
}
#[test]
fn aliases_parse_to_canonical_chords() {
let cases = [
@@ -50,6 +66,8 @@ fn aliases_parse_to_canonical_chords() {
("Cmd+Plus", "Cmd++"),
("cmd+Equals", "Cmd+="),
("Cmd+-", "Cmd+-"),
("Cmd+−", "Cmd+-"),
("⌘−", "Cmd+-"),
("Cmd + K", "Cmd+K"),
("esc", "Escape"),
("return", "Enter"),
+15 -10
View File
@@ -106,9 +106,7 @@ let laid_out = craft_menu::layout(&menu_bar, Target::current(), &LayoutOptions::
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) {
if !craft_quit::on_menu_event(ui.ctx(), &command.id) {
app.run(&command);
}
app.run(&command);
}
});
```
@@ -129,9 +127,9 @@ egui::Panel::top("menu-bar").show(ui, |ui| {
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)", 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
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`),
@@ -147,10 +145,13 @@ egui::Panel::top("menu-bar").show(ui, |ui| {
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`.
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 returns it like any other command; pass it to
`craft_quit::on_menu_event`, which asks the window to close so the unsaved-changes check runs.
(`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
@@ -186,7 +187,11 @@ so the app's unsaved-changes prompt runs). Each menu event wakes egui, so a chos
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.
`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.
+44 -6
View File
@@ -18,6 +18,7 @@ use egui::containers::menu::{MenuConfig, MenuState, SubMenu, SubMenuButton};
use egui::text::LayoutJob;
use egui::{Color32, Id, PopupCloseBehavior, Response, TextFormat, TextStyle, Ui, UiKind, UiStackInfo, WidgetText};
use crate::model::Builtin;
use crate::{Gate, Item, ItemKind, Menu, MenuBar, Node, Target};
use keys::Keys;
use nav::Nav;
@@ -106,6 +107,7 @@ pub struct MenuBarUi<'a> {
english: Option<&'a EnglishHook<'a>>,
preferred: &'a [(&'a str, char)],
id: Id,
fit: Option<f32>,
}
impl<'a> MenuBarUi<'a> {
@@ -120,6 +122,7 @@ impl<'a> MenuBarUi<'a> {
english: None,
preferred: &[],
id: Id::unique("craft-menu-bar"),
fit: None,
}
}
@@ -154,7 +157,7 @@ impl<'a> MenuBarUi<'a> {
/// The English label of a row or title, for labels the keyboard can't type a letter of
/// (Chinese, Japanese, Korean…): such a label shows its English label's mnemonic after it,
/// "文件(F)", as Windows menus do. Without this hook (or when it returns `None`) such a label
/// "文件(F)", before a trailing ellipsis ("開く(O)…"), as Windows menus do. Without this hook (or when it returns `None`) such a label
/// has no mnemonic. Not asked on a Mac target, which has no mnemonics.
pub fn english(mut self, english: &'a dyn Fn(Labeled<'_>) -> Option<String>) -> MenuBarUi<'a> {
self.english = Some(english);
@@ -170,6 +173,14 @@ impl<'a> MenuBarUi<'a> {
self
}
/// Take only the width the titles need, at most `max_width` (`f32::INFINITY` for no limit)
/// and the room the `Ui` has, so that other controls can share the row; past that the menus
/// overflow into "⋯". By default the bar fills the row.
pub fn shrink_to_fit(mut self, max_width: f32) -> MenuBarUi<'a> {
self.fit = Some(max_width);
self
}
/// The bar's id, where its state lives in egui memory: tells two bars in one window apart.
pub fn id(mut self, id: Id) -> MenuBarUi<'a> {
self.id = id;
@@ -178,6 +189,8 @@ impl<'a> MenuBarUi<'a> {
/// Draw the bar and its open menu; returns the commands chosen this frame (at most one),
/// clicked or run with ↩ or Space. Disabled items, and items the gate forbids, never run.
/// [`Builtin::Quit`] (asks the window to close, see [`craft_quit::on_menu_event`]) and
/// [`Builtin::Minimize`] run here and are left out.
pub fn show(self, ui: &mut Ui) -> Vec<CommandRef> {
let ctx = ui.ctx().clone();
let style = self.style.clone().unwrap_or_else(|| BarStyle::from_style(ui.style()));
@@ -207,7 +220,13 @@ impl<'a> MenuBarUi<'a> {
press: self.id.with("press"),
clicked: None,
};
ui.scope(|ui| {
let room = self.fit.map_or(ui.available_width(), |max| max.min(ui.available_width())).max(0.0);
let width = match self.fit {
// The half point keeps rounding from pushing the last title into "⋯".
Some(_) => (self.title_widths(ui, &draw, style.title_padding.x).iter().sum::<f32>() + 0.5).min(room),
None => room,
};
ui.allocate_ui(egui::vec2(width, ui.available_height()), |ui| {
ui.spacing_mut().item_spacing.x = 0.0;
egui::MenuBar::new().ui(ui, |ui| {
ctx.accesskit_node_builder(ui.unique_id(), |n| n.set_role(Role::MenuBar));
@@ -282,7 +301,28 @@ impl<'a> MenuBarUi<'a> {
if ctx.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) {
ctx.data_mut(|d| d.remove::<bool>(draw.press));
}
draw.clicked.into_iter().collect()
draw.clicked
.into_iter()
.filter(|command| match Builtin::of(command) {
Some(Builtin::Quit) => !craft_quit::on_menu_event(&ctx, Builtin::Quit.id()),
Some(Builtin::Minimize) => {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
false
}
_ => true,
})
.collect()
}
/// Each title's width as shown, with any "(F)" it takes, and `pad` on either side.
fn title_widths(&self, ui: &Ui, draw: &Draw<'_>, pad: f32) -> Vec<f32> {
let font = TextStyle::Button.resolve(ui.style());
let names: Vec<(&str, Name<'_>)> = self.bar.menus.iter().map(|m| (m.title.as_str(), Name::App(Labeled::Title(&m.title)))).collect();
draw.mnemonics(&names)
.0
.iter()
.map(|t| ui.painter().layout_no_wrap(t.to_owned(), font.clone(), Color32::PLACEHOLDER).size().x + 2.0 * pad)
.collect()
}
/// The titles to draw: every menu when they fit in the row; else the menus that fit with
@@ -293,9 +333,7 @@ impl<'a> MenuBarUi<'a> {
let pad = 2.0 * ui.spacing().button_padding.x;
let width = |t: &str| ui.painter().layout_no_wrap(t.to_owned(), font.clone(), Color32::PLACEHOLDER).size().x + pad;
let room = ui.available_width();
// Measured as shown, with any "(F)" a title takes.
let names: Vec<(&str, Name<'_>)> = menus.iter().map(|m| (m.title.as_str(), Name::App(Labeled::Title(&m.title)))).collect();
let widths: Vec<f32> = draw.mnemonics(&names).0.iter().map(|t| width(t)).collect();
let widths = self.title_widths(ui, draw, ui.spacing().button_padding.x);
if widths.iter().sum::<f32>() <= room {
return menus.iter().map(Top::Menu).collect();
}
+40 -5
View File
@@ -73,11 +73,40 @@ thread_local! {
/// 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);
let mut menu = MacMenu::new(ctx, options);
if !menu.build(bar) {
INSTALLED.store(false, Ordering::SeqCst);
return None;
@@ -237,10 +266,11 @@ struct MacMenu {
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) -> MacMenu {
fn new(ctx: &egui::Context, options: MacOptions) -> MacMenu {
let (tx, rx) = channel();
MacMenu {
ctx: ctx.clone(),
@@ -252,6 +282,7 @@ impl MacMenu {
structure: 0,
window: None,
pending: None,
options,
}
}
@@ -274,7 +305,7 @@ impl MacMenu {
if m.role == MenuRole::Window {
window = Some((sub.clone(), ids, window_key(bar)));
}
if matches!(m.role, MenuRole::Window | MenuRole::Help) {
if m.role == MenuRole::Help || (m.role == MenuRole::Window && self.options.window_list) {
special.push((m.role, sub));
}
}
@@ -302,7 +333,9 @@ impl MacMenu {
if let Some(old) = &self.menu {
let _ = catch_unwind(AssertUnwindSafe(|| {
old.init_for_nsapp();
if let Some((sub, ..)) = &self.window {
if let Some((sub, ..)) = &self.window
&& self.options.window_list
{
sub.set_as_windows_menu_for_nsapp();
}
}));
@@ -334,7 +367,9 @@ impl MacMenu {
let _ = menu.insert(&old, at);
return Err(e);
}
sub.set_as_windows_menu_for_nsapp();
if self.options.window_list {
sub.set_as_windows_menu_for_nsapp();
}
Ok((sub, ids, window_key(bar)))
}));
match replaced {
+45 -3
View File
@@ -65,6 +65,11 @@ struct Bar {
pointer: Pos2,
tree: Vec<(NodeId, accesskit::Node)>,
clicked: Vec<CommandRef>,
/// `shrink_to_fit`'s limit; the bar then shares a row with a label, which starts at `after`.
fit: Option<f32>,
after: Option<f32>,
/// The viewport commands the bar sent.
sent: Vec<egui::ViewportCommand>,
}
impl Bar {
@@ -88,6 +93,9 @@ impl Bar {
pointer: pos2(-10.0, -10.0),
tree: Vec::new(),
clicked: Vec::new(),
fit: None,
after: None,
sent: Vec::new(),
};
b.run(3);
b
@@ -102,16 +110,29 @@ impl Bar {
events: std::mem::take(&mut self.events),
..Default::default()
};
let (bar, gate, target, english, preferred) = (&self.bar, self.gate, self.target, self.english, &self.preferred);
let (bar, gate, target, english, preferred, fit) = (&self.bar, self.gate, self.target, self.english, &self.preferred, self.fit);
let mut out = Vec::new();
let mut after = None;
let mut full = self.ctx.run_ui(raw, |ui| {
let mut ui_bar = MenuBarUi::new(bar).gate(gate).target(target).mnemonics(preferred);
if let Some(english) = &english {
ui_bar = ui_bar.english(english);
}
out = ui_bar.show(ui);
match fit {
Some(max) => {
ui.horizontal(|ui| {
out = ui_bar.shrink_to_fit(max).show(ui);
after = Some(ui.label("after").rect.left());
});
}
None => out = ui_bar.show(ui),
}
});
full.textures_delta.clear();
self.after = after;
if let Some(root) = full.viewport_output.remove(&egui::ViewportId::ROOT) {
self.sent.extend(root.commands);
}
if let Some(update) = full.platform_output.accesskit_update {
self.tree = update.nodes;
}
@@ -273,7 +294,8 @@ fn disabled_and_gate_forbidden_items_return_nothing() {
b.click("New");
assert!(b.take().is_empty(), "and they don't run");
b.click("Quit");
assert_eq!(b.take(), vec![Builtin::Quit.command()], "the built-in Quit still runs");
assert!(b.take().is_empty(), "the bar runs the built-in Quit itself");
assert!(b.sent.contains(&egui::ViewportCommand::Close), "and it still runs: the window is asked to close");
b.gate = Gate { text_editing: true, ..Gate::default() };
b.click("File");
@@ -446,6 +468,26 @@ fn the_web_shows_the_browsers_system_names_and_the_mac_its_glyphs() {
}
}
/// `shrink_to_fit` leaves the rest of the row to other controls; past its limit the menus
/// overflow into "⋯".
#[test]
fn a_bar_shrunk_to_fit_shares_its_row() {
let mut b = wide();
b.fit = Some(f32::INFINITY);
b.run(2);
let after = b.after.expect("the label after the bar");
assert!(after < b.size.x / 2.0, "the bar takes only its titles' width: {after}");
assert!(b.title("Help").is_some() && b.title("⋯").is_none());
b.fit = Some(100.0);
b.run(2);
assert!(b.title("⋯").is_some(), "the menus past 100 points overflow");
// The bar's 100 points and the row's item spacing (8).
assert!(b.after.is_some_and(|x| x <= 109.0), "{:?}", b.after);
// A limit an app computed from a full row can go negative.
b.fit = Some(-20.0);
b.run(2);
}
/// A menu far taller than the window.
fn tall() -> Bar {
let rows = (0..60).map(|i| item(&format!("cmd.{i}"), &format!("Item {i}")).into()).collect();