diff --git a/crates/keymap/src/chord.rs b/crates/keymap/src/chord.rs index 3d079868..df1ecb51 100644 --- a/crates/keymap/src/chord.rs +++ b/crates/keymap/src/chord.rs @@ -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::ALL .iter() diff --git a/crates/keymap/tests/chord.rs b/crates/keymap/tests/chord.rs index 116b357e..4a8440d7 100644 --- a/crates/keymap/tests/chord.rs +++ b/crates/keymap/tests/chord.rs @@ -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"), diff --git a/crates/menu/README.md b/crates/menu/README.md index 42c77b30..063c2d0e 100644 --- a/crates/menu/README.md +++ b/crates/menu/README.md @@ -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. diff --git a/crates/menu/src/bar/mod.rs b/crates/menu/src/bar/mod.rs index 0a79c102..d034b9ad 100644 --- a/crates/menu/src/bar/mod.rs +++ b/crates/menu/src/bar/mod.rs @@ -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, } 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) -> 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 { 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::() + 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::(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 { + 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 = 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::() <= room { return menus.iter().map(Top::Menu).collect(); } diff --git a/crates/menu/src/mac.rs b/crates/menu/src/mac.rs index f4f4126f..e5bdd306 100644 --- a/crates/menu/src/mac.rs +++ b/crates/menu/src/mac.rs @@ -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 { + 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 { 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, /// The latest menus, waiting for the open menu to close. pending: Option, + 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 { diff --git a/crates/menu/src/tests/bar.rs b/crates/menu/src/tests/bar.rs index 9375dae9..5227839b 100644 --- a/crates/menu/src/tests/bar.rs +++ b/crates/menu/src/tests/bar.rs @@ -65,6 +65,11 @@ struct Bar { pointer: Pos2, tree: Vec<(NodeId, accesskit::Node)>, clicked: Vec, + /// `shrink_to_fit`'s limit; the bar then shares a row with a label, which starts at `after`. + fit: Option, + after: Option, + /// The viewport commands the bar sent. + sent: Vec, } 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();