Files
craft-libs/crates/menu/src/tests/layout.rs
T
aiongg 9f89fe8f6d Add craft-menu: one menu definition, native on macOS and an egui bar on Windows, Linux and web (#66)
* 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
2026-10-25 21:59:22 -04:00

147 lines
8.1 KiB
Rust

//! The Windows, Linux and web layouts, platform filtering, and the keymap adapter.
use craft_keymap::{Bindable, Binding, Keymap, Platform, Policy, Scope, Scopes};
use super::*;
fn menu(bar: &MenuBar, role: MenuRole) -> &Menu {
bar.menu(role).unwrap()
}
fn has(bar: &MenuBar, id: &str) -> bool {
bar.find(&cmd(id)).is_some()
}
#[test]
fn windows_puts_exit_last_in_file_and_documents_in_window() {
let l = layout(&sample(false, true), Target::Windows, &LayoutOptions::new("Sample"));
assert!(l.clashes.is_empty());
assert_eq!(ids(&menu(&l.bar, MenuRole::File).children), ["file.new", "---", "file.close", "---", "file.exit"]);
assert_eq!(l.bar.find(&cmd("file.exit")).unwrap().label, "Exit");
assert_eq!(ids(&menu(&l.bar, MenuRole::Edit).children), ["edit.undo", "edit.redo", "---", "[Language]", "---", "[Preferences]"]);
assert_eq!(ids(&menu(&l.bar, MenuRole::Help).children), ["help.docs", "---", "help.about"]);
assert_eq!(l.bar.find(&cmd("help.about")).unwrap().label, "About Sample");
assert_eq!(ids(&menu(&l.bar, MenuRole::Window).children), ["[Theme]", "theme.next", "panel.layers", "---", "doc.0"]);
assert_eq!(l.bar.find(&cmd("doc.0")).unwrap().kind, ItemKind::Radio(true), "the document in front is selected");
assert!(!has(&l.bar, Builtin::Hide.id()) && !has(&l.bar, Builtin::Minimize.id()));
assert_eq!(l.bar.menus.len(), 5);
let mac = mac_layout(&sample(false, true), &LayoutOptions::new("Sample")).bar;
assert_eq!(mac.find(&cmd("doc.0")).unwrap().kind, ItemKind::Radio(true));
}
/// Without the menus the moved items need, the layout adds them, with translated titles.
#[test]
fn linux_adds_quit_and_the_menus_it_needs() {
let view = vec![
Item::new("help.about", "About").role(ItemRole::About).into(),
Item::new("app.settings", "Settings").role(ItemRole::Settings).into(),
Item::new("view.zoom", "Zoom").into(),
];
let mut bar = MenuBar { menus: vec![Menu::new("View", view).role(MenuRole::View)], documents: vec![Document::new("doc.0", "Untitled")] };
bar.documents.push(Document { hidden: true, ..Document::new("doc.1", "Background") });
let label = |s: &'static str| format!("«{s}»");
let l = layout(&bar, Target::Linux, &LayoutOptions { label: &label, ..LayoutOptions::new("Sample") });
let titles: Vec<&str> = l.bar.menus.iter().map(|m| m.title.as_str()).collect();
assert_eq!(titles, ["«File»", "«Edit»", "View", "«Window»", "«Help»"]);
let quit = &menu(&l.bar, MenuRole::File).children;
assert_eq!(ids(quit), [Builtin::Quit.id()]);
assert_eq!(l.bar.find(&Builtin::Quit.command()).unwrap().label, "«Quit»");
assert_eq!(l.bar.find(&cmd("app.settings")).unwrap().label, "«Preferences…»");
assert_eq!(l.bar.find(&cmd("help.about")).unwrap().label, "«About Sample»");
assert_eq!(ids(&menu(&l.bar, MenuRole::Window).children), ["doc.0"], "a hidden document isn't listed");
}
#[test]
fn the_web_has_no_quit_and_gives_the_browser_its_shortcuts() {
let mut bar = sample(false, true);
let file = bar.menus.iter_mut().find(|m| m.role == MenuRole::File).unwrap();
file.children.push(Item::new("file.reopen", "Reopen").shortcut(chord("Cmd+Shift+T")).into());
file.children.push(Item::new("file.tab", "Tab").shortcut(chord("Cmd+Alt+T")).into());
let l = layout(&bar, Target::Web, &LayoutOptions::new("Sample"));
assert!(!has(&l.bar, "file.exit") && !has(&l.bar, Builtin::Quit.id()));
let clashes: Vec<(String, &str)> = l.clashes.iter().map(|c| (c.command.key(), c.system)).collect();
assert_eq!(clashes, [("file.new".into(), "New Window"), ("file.reopen".into(), "Reopen Closed Tab")]);
assert_eq!(l.bar.find(&cmd("file.new")).unwrap().shortcut, None);
assert_eq!(l.bar.find(&cmd("file.tab")).unwrap().shortcut, Some(chord("Cmd+Alt+T")));
assert_eq!(l.bar.find(&cmd("edit.undo")).unwrap().shortcut, Some(chord("Cmd+Z")));
}
/// Items and submenus show only on their platforms; a submenu left empty goes, one holding only
/// the app's disabled placeholder stays.
#[test]
fn layouts_keep_only_what_the_target_shows() {
let file = vec![
Item::new("file.new", "New").into(),
Item::new("file.tabs", "Show Tab Bar").platforms(Platforms::MAC).into(),
Node::submenu("Export", vec![Item::new("file.export_native", "Native").platforms(Platforms::MAC).into()]),
Node::submenu("Open Recent", vec![Item::new("file.no_recent", "No Recent Files").enabled(false).into()]),
Node::submenu("Plugins", vec![Item::new("plugin.a", "A").into()]).platforms(Platforms::ALL.without(Platforms::WEB)),
];
let bar = MenuBar { menus: vec![Menu::new("File", file).role(MenuRole::File)], documents: Vec::new() };
let file_on = |target| {
let l = layout(&bar, target, &LayoutOptions::new("Sample"));
let file = menu(&l.bar, MenuRole::File).children.iter().take_while(|n| **n != Node::Separator).cloned().collect::<Vec<_>>();
ids(&file)
};
assert_eq!(file_on(Target::Mac), ["file.new", "file.tabs", "[Export]", "[Open Recent]", "[Plugins]"]);
assert_eq!(file_on(Target::Linux), ["file.new", "[Open Recent]", "[Plugins]"]);
assert_eq!(file_on(Target::Web), ["file.new", "[Open Recent]"]);
}
fn keymap() -> Keymap {
let app = Scope::app();
let layers = Scope::new("Layers").unwrap();
let catalog = vec![
Bindable::new("file.save", "Save", app.clone()).with_default(Binding::new(chord("Cmd+S"))),
Bindable::new("view.rulers", "Rulers", app.clone()).toggle().with_default(Binding::new(chord("Cmd+R"))),
Bindable::new("view.grid", "Grid", app.clone()).toggle(),
Bindable::new("layer.new", "New Layer", layers.clone())
.with_default(Binding::in_scope(chord("Cmd+Shift+N"), app.clone()))
.with_default(Binding::new(chord("Cmd+L"))),
Bindable::new("view.mode", "Mode", app).with_default(Binding::new(chord("M"))),
];
Keymap::new(catalog, Scopes::new().with_panel("Layers"), Platform::Linux)
}
#[test]
fn the_keymap_gives_shortcuts_and_check_items() {
let mut keys = keymap();
keys.set_bindings(&cmd("file.save"), vec![Binding::new(chord("Cmd+Alt+S"))], Policy::Steal).unwrap();
let view = vec![
Item::new("file.save", "Save").shortcut(chord("Cmd+S")).into(),
Item::new("view.rulers", "Rulers").into(),
Item::new("view.grid", "Grid").check(true).into(),
Item::new("layer.new", "New Layer").into(),
Item::new("view.mode", "Mode").radio(true).into(),
Item::new("view.other", "Other").shortcut(chord("F9")).into(),
];
let mut bar = MenuBar { menus: vec![Menu::new("View", view)], documents: Vec::new() };
apply_keymap(&mut bar, &keys);
let item = |id: &str| bar.find(&cmd(id)).unwrap().clone();
assert_eq!(item("file.save").shortcut, Some(chord("Cmd+Alt+S")), "the user's override");
assert_eq!((item("view.rulers").shortcut, item("view.rulers").kind), (Some(chord("Cmd+R")), ItemKind::Check(false)));
assert_eq!((item("view.grid").shortcut, item("view.grid").kind), (None, ItemKind::Check(true)), "a toggle keeps its state");
assert_eq!(item("layer.new").shortcut, Some(chord("Cmd+L")), "the home scope's binding first");
assert_eq!(item("view.mode").kind, ItemKind::Radio(true));
assert_eq!(item("view.other").shortcut, Some(chord("F9")), "a command the keymap doesn't know keeps its shortcut");
}
#[test]
fn the_app_sets_enabled_and_checked_each_frame() {
let mut bar = mac_layout(&sample(false, true), &LayoutOptions::new("Sample")).bar;
let mut asked = Vec::new();
apply_state(&mut bar, |c| {
asked.push(c.key());
match c.id.as_str() {
"edit.undo" => ItemState { enabled: false, checked: None },
"panel.layers" => ItemState { checked: Some(true), ..ItemState::default() },
_ => ItemState::default(),
}
});
assert!(!bar.find(&cmd("edit.undo")).unwrap().enabled);
assert_eq!(bar.find(&cmd("panel.layers")).unwrap().kind, ItemKind::Check(true));
assert!(asked.contains(&"file.new".to_string()));
assert!(!asked.iter().any(|k| k == Builtin::Hide.id() || k == "doc.0"), "built-ins and documents are left alone: {asked:?}");
}