mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-08 19:42:12 +00:00
* 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
197 lines
8.7 KiB
Rust
197 lines
8.7 KiB
Rust
use craft_keymap::chord::foreign::{Flavor, ForeignError, ReadNote, read_foreign};
|
||
use craft_keymap::{Chord, Key, MAX_CHORD_TEXT, Mods, ParseError, Platform, Trigger};
|
||
|
||
fn chord(text: &str) -> Chord {
|
||
Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}"))
|
||
}
|
||
|
||
fn mods(cmd: bool, ctrl: bool, alt: bool, shift: bool) -> Mods {
|
||
Mods { cmd, ctrl, alt, shift }
|
||
}
|
||
|
||
#[test]
|
||
fn canonical_text_round_trips_for_every_key_and_modifier_set() {
|
||
for &key in Key::ALL {
|
||
for bits in 0..16u8 {
|
||
let c = Chord::new(mods(bits & 1 != 0, bits & 2 != 0, bits & 4 != 0, bits & 8 != 0), key);
|
||
let text = c.to_string();
|
||
assert_eq!(Chord::parse(&text), Ok(c), "{text}");
|
||
assert_eq!(Trigger::parse(&format!("{text} {text}")), Ok(Trigger::double(c)), "{text} double");
|
||
let json = serde_json::to_string(&Trigger::single(c)).unwrap();
|
||
assert_eq!(serde_json::from_str::<Trigger>(&json).unwrap(), Trigger::single(c));
|
||
// macOS display text reads back as the same chord.
|
||
assert_eq!(Chord::parse(&c.display(Platform::Mac)), Ok(c), "{}", c.display(Platform::Mac));
|
||
}
|
||
}
|
||
assert_eq!(chord("Cmd+Ctrl+Alt+Shift+K").to_string(), "Cmd+Ctrl+Alt+Shift+K");
|
||
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 = [
|
||
("cmd+shift+z", "Cmd+Shift+Z"),
|
||
("Shift+Command+Z", "Cmd+Shift+Z"),
|
||
("meta+z", "Cmd+Z"),
|
||
("Super+Z", "Cmd+Z"),
|
||
("mod+z", "Cmd+Z"),
|
||
("⌘⇧Z", "Cmd+Shift+Z"),
|
||
("⌃⌥⇧⌘K", "Cmd+Ctrl+Alt+Shift+K"),
|
||
("⌘+K", "Cmd+K"),
|
||
("⌘Shift+K", "Cmd+Shift+K"),
|
||
("control+opt+left", "Ctrl+Alt+Left"),
|
||
("ctl+option+ArrowUp", "Ctrl+Alt+Up"),
|
||
// craft-menu writes Ctrl before Cmd.
|
||
("Ctrl+Cmd+Alt+Shift+M", "Cmd+Ctrl+Alt+Shift+M"),
|
||
("Ctrl+Cmd+F", "Cmd+Ctrl+F"),
|
||
("Cmd++", "Cmd++"),
|
||
("⌘+", "Cmd++"),
|
||
("+", "+"),
|
||
("Cmd+Plus", "Cmd++"),
|
||
("cmd+Equals", "Cmd+="),
|
||
("Cmd+-", "Cmd+-"),
|
||
("Cmd+−", "Cmd+-"),
|
||
("⌘−", "Cmd+-"),
|
||
("Cmd + K", "Cmd+K"),
|
||
("esc", "Escape"),
|
||
("return", "Enter"),
|
||
("del", "Delete"),
|
||
("pgup", "PageUp"),
|
||
("pgdn", "PageDown"),
|
||
("arrowdown", "Down"),
|
||
("spacebar", "Space"),
|
||
("ins", "Insert"),
|
||
("f12", "F12"),
|
||
("Num5", "5"),
|
||
("Semicolon", ";"),
|
||
("Questionmark", "?"),
|
||
("OpenCurlyBracket", "{"),
|
||
("Backtick", "`"),
|
||
("⌘⌫", "Cmd+Backspace"),
|
||
];
|
||
for (text, canonical) in cases {
|
||
assert_eq!(chord(text).to_string(), canonical, "{text}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_text_is_refused() {
|
||
let long = "Cmd+".repeat(MAX_CHORD_TEXT / 4) + "K";
|
||
assert!(long.len() > MAX_CHORD_TEXT);
|
||
let cases: &[(&str, ParseError)] = &[
|
||
("", ParseError::Empty),
|
||
(" ", ParseError::Empty),
|
||
(&long, ParseError::TooLong),
|
||
("+++", ParseError::UnknownModifier("+".into())),
|
||
("++", ParseError::MissingKey),
|
||
("Cmd+", ParseError::MissingKey),
|
||
("Cmd", ParseError::MissingKey),
|
||
("⌘", ParseError::MissingKey),
|
||
("Shift+Alt", ParseError::MissingKey),
|
||
("Cmd+Command+K", ParseError::DuplicateModifier("Command".into())),
|
||
("⌘⌘K", ParseError::DuplicateModifier("⌘".into())),
|
||
("Cmd+é", ParseError::UnknownKey("é".into())),
|
||
("Ctrl+ж+K", ParseError::UnknownModifier("ж".into())),
|
||
("Hyper+K", ParseError::UnknownModifier("Hyper".into())),
|
||
("A B", ParseError::Sequence),
|
||
("Cmd+K Cmd+J", ParseError::Sequence),
|
||
("A A A", ParseError::UnknownKey("A A A".into())),
|
||
];
|
||
for (text, err) in cases {
|
||
assert_eq!(Trigger::parse(text).as_ref(), Err(err), "{text:?}");
|
||
}
|
||
let wide = "⌘".repeat(30);
|
||
assert_eq!(Chord::parse(&wide), Err(ParseError::TooLong));
|
||
assert!(serde_json::from_str::<Chord>("\"Cmd+Nope\"").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn display_follows_the_platform() {
|
||
let c = chord("Cmd+Ctrl+Alt+Shift+K");
|
||
assert_eq!(c.display(Platform::Mac), "⌃⌥⇧⌘K");
|
||
assert_eq!(c.display(Platform::Windows), "Ctrl+Alt+Shift+K");
|
||
assert_eq!(chord("Ctrl+Up").display(Platform::Mac), "⌃↑");
|
||
assert_eq!(chord("Ctrl+Up").display(Platform::Linux), "Ctrl+Up");
|
||
assert_eq!(chord("Cmd+Enter").display(Platform::Mac), "⌘⏎");
|
||
assert_eq!(chord("Shift+Tab").display(Platform::Mac), "⇧⇥");
|
||
assert_eq!(chord("Space").display(Platform::Mac), "Space");
|
||
assert_eq!(chord("Alt+PageDown").display(Platform::Windows), "Alt+Page Down");
|
||
assert_eq!(Trigger::parse("Cmd+K Cmd+K").unwrap().display(Platform::Mac), "⌘K ⌘K");
|
||
assert_eq!(Trigger::parse("A A").unwrap().to_string(), "A A");
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_folds_into_cmd_off_macos() {
|
||
let ctrl = chord("Ctrl+S");
|
||
let cmd = chord("Cmd+S");
|
||
assert_ne!(ctrl.effective(Platform::Mac), cmd.effective(Platform::Mac));
|
||
for p in [Platform::Windows, Platform::Linux] {
|
||
assert_eq!(ctrl.effective(p), cmd);
|
||
assert_eq!(chord("Cmd+Ctrl+S").effective(p), cmd);
|
||
}
|
||
assert!(ctrl.has_command() && cmd.has_command());
|
||
assert!(chord("Shift+B").is_plain() && !chord("Alt+B").is_plain());
|
||
assert!(chord("Shift+F35").is_function_key() && !chord("F").is_function_key());
|
||
}
|
||
|
||
#[test]
|
||
fn foreign_windows_text() {
|
||
let read = |t: &str| read_foreign(t, Flavor::Windows);
|
||
let ok = |t: &str| read(t).unwrap_or_else(|e| panic!("{t}: {e}"));
|
||
assert_eq!(ok("Shift+Ctrl+S"), (Trigger::single(chord("Cmd+Shift+S")), vec![]));
|
||
assert_eq!(ok("Ctrl+,").0, Trigger::single(chord("Cmd+,")));
|
||
assert_eq!(ok(",").0, Trigger::single(chord(",")));
|
||
assert_eq!(ok("Ctrl++").0, Trigger::single(chord("Cmd++")));
|
||
assert_eq!(ok("Ctrl+-").0, Trigger::single(chord("Cmd+-")));
|
||
assert_eq!(ok("Alt-F4").0, Trigger::single(chord("Alt+F4")));
|
||
assert_eq!(ok("Ctrl + S").0, Trigger::single(chord("Cmd+S")));
|
||
assert_eq!(ok("Shift + Ctrl + S").0, Trigger::single(chord("Cmd+Shift+S")));
|
||
assert_eq!(ok("Ctrl + +").0, Trigger::single(chord("Cmd++")));
|
||
assert_eq!(ok("Alt - F4").0, Trigger::single(chord("Alt+F4")));
|
||
assert_eq!(ok("Ctrl Page Down").0, Trigger::single(chord("Cmd+PageDown")));
|
||
assert_eq!(ok("Shift+Up Arrow").0, Trigger::single(chord("Shift+Up")));
|
||
assert_eq!(ok("Num +"), (Trigger::single(chord("+")), vec![ReadNote::Numpad]));
|
||
assert_eq!(ok("Ctrl+Num /"), (Trigger::single(chord("Cmd+/")), vec![ReadNote::Numpad]));
|
||
assert_eq!(ok("Numpad 5"), (Trigger::single(chord("5")), vec![ReadNote::Numpad]));
|
||
assert_eq!(ok("Num Enter"), (Trigger::single(chord("Enter")), vec![ReadNote::Numpad]));
|
||
assert_eq!(read("Win+E"), Err(ForeignError::UnsupportedModifier("Win".into())));
|
||
assert_eq!(read("Meta+E"), Err(ForeignError::UnsupportedModifier("Meta".into())));
|
||
assert_eq!(read(""), Err(ForeignError::Empty));
|
||
assert_eq!(read(&"x".repeat(MAX_CHORD_TEXT + 1)), Err(ForeignError::TooLong));
|
||
assert_eq!(read("Ctrl+Num Lock"), Err(ForeignError::UnknownKey("Num Lock".into())));
|
||
assert_eq!(read("Alt"), Err(ForeignError::UnknownKey("Alt".into())));
|
||
}
|
||
|
||
#[test]
|
||
fn foreign_mac_text() {
|
||
let texts = ["Shift+Cmd+S", "Ctrl+Opt+Left"];
|
||
assert_eq!(Flavor::detect(texts), Flavor::Mac);
|
||
assert_eq!(Flavor::detect(["Shift+Ctrl+S", "Alt+F4"]), Flavor::Windows);
|
||
assert_eq!(Flavor::detect(["⌥⌘K"]), Flavor::Mac);
|
||
let ok = |t: &str| read_foreign(t, Flavor::Mac).unwrap_or_else(|e| panic!("{t}: {e}")).0;
|
||
assert_eq!(ok("Shift+Cmd+S"), Trigger::single(chord("Cmd+Shift+S")));
|
||
assert_eq!(ok("Ctrl+Opt+Left"), Trigger::single(chord("Ctrl+Alt+Left")));
|
||
assert_eq!(ok("Control-Option-Space"), Trigger::single(chord("Ctrl+Alt+Space")));
|
||
assert_eq!(ok("⇧⌘S"), Trigger::single(chord("Cmd+Shift+S")));
|
||
assert_eq!(ok("⌃⌥⇞"), Trigger::single(chord("Ctrl+Alt+PageUp")));
|
||
assert_eq!(ok("Cmd+,"), Trigger::single(chord("Cmd+,")));
|
||
// In a Windows set the same ⌃ is the command modifier.
|
||
assert_eq!(read_foreign("⌃S", Flavor::Windows).map(|r| r.0), Ok(Trigger::single(chord("Cmd+S"))));
|
||
}
|