//! The macOS menu bar's model ([`crate::native_menu`]), tested on every platform with a fake //! backend: the Mac layout of the in-window menus, the keys it hands back to egui, the clicks it //! runs and when it reads the menus again. use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; use egui::{Event, Key, KeyboardShortcut, Modifiers, PointerButton, Pos2, Rect, vec2}; use serde_json::{Value, json}; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES; use crate::i18n::Lang; use crate::native_menu::{self, Backend, MenuBar, MenuRole, NativeMenu, Node, Standard}; use crate::{VectorcraftApp, menus}; fn app(doc: bool) -> VectorcraftApp { let mut app = VectorcraftApp::new(Session::new(), Default::default()); if doc { app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 37, "height": 40})).unwrap(); } app } fn bar(app: &VectorcraftApp) -> MenuBar { native_menu::mac_layout(app, &native_menu::from_tree(app, &menus::menu_tree()), Lang::EN).bar } /// A level's nodes as short names: an item's command (`---` a separator, `[label]` a submenu, /// `` an AppKit item). fn ids(nodes: &[Node]) -> Vec { nodes .iter() .map(|n| match n { Node::Item(it) => it.command.unwrap_or("").to_string(), Node::Separator => "---".into(), Node::Header(h) => format!("#{h}"), Node::Submenu { label, .. } => format!("[{label}]"), Node::Standard(s) => format!("<{s:?}>"), }) .collect() } fn menu<'a>(bar: &'a MenuBar, title: &str) -> &'a [Node] { &bar.menus.iter().find(|m| m.title == title).unwrap_or_else(|| panic!("no {title} menu")).children } fn submenu<'a>(nodes: &'a [Node], name: &str) -> &'a [Node] { nodes .iter() .find_map(|n| match n { Node::Submenu { label, children } if label == name => Some(children.as_slice()), _ => None, }) .unwrap_or_else(|| panic!("no {name} submenu in {:?}", ids(nodes))) } fn items(nodes: &[Node]) -> Vec<&native_menu::Item> { nodes.iter().filter_map(|n| if let Node::Item(it) = n { Some(it) } else { None }).collect() } fn find<'a>(bar: &'a MenuBar, command: &str) -> &'a native_menu::Item { bar.items().into_iter().find(|it| it.command == Some(command)).unwrap_or_else(|| panic!("no {command} item")) } #[test] fn vectorcraft_gets_a_mac_app_menu() { let bar = bar(&app(true)); let titles: Vec<&str> = bar.menus.iter().map(|m| m.title.as_str()).collect(); assert_eq!(titles, ["VectorCraft", "File", "Edit", "Object", "Type", "Select", "Effect", "View", "Window", "Help"]); let app_menu = &bar.menus[0]; assert_eq!(app_menu.role, MenuRole::App); assert_eq!( ids(&app_menu.children), [ "help.about", "---", "[Settings]", "[Language]", "[Appearance]", "---", "", "---", native_menu::HIDE, native_menu::HIDE_OTHERS, "", "---", "app.quit" ] ); let shown = |command: &str| { let it = find(&bar, command); (it.label.as_str(), it.shortcut) }; assert_eq!(shown("help.about"), ("About VectorCraft", None)); assert_eq!(shown("app.quit"), ("Quit VectorCraft", Some("Cmd+Q")), "Quit is the app's own, so unsaved documents are asked about"); // ⌘H stays View › Hide Edges (Illustrator's); Hide takes ⌃⌘H. assert_eq!(shown(native_menu::HIDE), ("Hide VectorCraft", Some("Ctrl+Cmd+H"))); assert_eq!(shown(native_menu::HIDE_OTHERS), ("Hide Others", Some("Cmd+Alt+H"))); assert_eq!(find(&bar, "view.edges").shortcut, Some("Cmd+H")); assert_eq!(bar.menu(MenuRole::Help).map(|m| m.title.as_str()), Some("Help")); } /// Settings ▸ lists the Preferences dialog's own pages, General… with ⌘K, and each opens the /// dialog on its page. #[test] fn settings_lists_every_preferences_page() { let mut app = app(true); let bar = bar(&app); let pages = items(submenu(&bar.menus[0].children, "Settings")); assert_eq!(pages.len(), PREF_CATEGORIES.len()); assert_eq!((pages[0].label.as_str(), pages[0].shortcut), ("General…", Some("Cmd+K"))); assert!(pages[1..].iter().all(|it| it.shortcut.is_none())); for (it, page) in pages.iter().zip(PREF_CATEGORIES) { assert_eq!((it.command, &it.params, it.label.clone()), (Some("edit.preferences"), &json!({"category": page}), format!("{page}…"))); } let units = pages.iter().find(|it| it.source == "Units").unwrap(); let (id, p) = menus::click_target(units.source, units.command.unwrap(), &units.params); assert_eq!(id, "edit.preferences", "a page opens the dialog, not a parameter form"); menus::invoke(&mut app, &id, p); let dialog = app.ui.dialog.as_ref().expect("Preferences open"); assert_eq!((dialog.kind.as_str(), dialog.fields.get("__category")), ("preferences", Some(&json!("Units")))); } /// Language ▸: Automatic, then each language in its own name (in any UI language), the /// preference checked; Appearance ▸: the UI brightness choices, the current one checked. #[test] fn language_and_appearance_are_in_the_app_menu() { let mut app = app(false); app.session.prefs.interface_language = "ja".into(); let bar = bar(&app); let app_menu = &bar.menus[0].children; assert_eq!(ids(submenu(app_menu, "Language"))[..2], ["app.language", "---"], "Automatic, then the languages"); let langs: Vec<(String, Option)> = items(submenu(app_menu, "Language")).iter().map(|it| (it.label.clone(), it.checked)).collect(); let mut want = vec![("Automatic".to_string(), Some(false))]; want.extend(Lang::all().map(|l| (l.name().to_string(), Some(l.code() == "ja")))); assert_eq!(langs, want); // In every UI language the languages keep their own names. for ui in Lang::all() { for l in Lang::all() { assert_eq!(crate::i18n::tr_id(ui, "app.language", l.name()), l.name(), "{} in {}", l.name(), ui.code()); } } let appearance: Vec<(&'static str, Option)> = items(submenu(app_menu, "Appearance")).iter().map(|it| (it.source, it.checked)).collect(); assert_eq!(appearance.len(), crate::theme::Brightness::ALL.len()); assert_eq!(appearance.iter().filter(|(_, c)| *c == Some(true)).count(), 1, "{appearance:?}"); assert!(appearance.iter().all(|(_, c)| c.is_some())); assert_eq!(crate::i18n::tr_ctx(Lang::from_code("ja").unwrap(), "theme", "Appearance"), "外観", "the theme, not the Appearance panel"); } /// Everything the in-window bar lists is in the Mac menu bar, once: what moved to the app menu /// (About, Settings, Quit, Language, UI Brightness) leaves its other places, and Join Our Discord /// stays under Help. #[test] fn every_in_window_item_is_in_the_mac_menu_bar_once() { let app = app(true); let count = |bar: &MenuBar| { let mut n: HashMap<(String, String), usize> = HashMap::new(); for it in bar.items() { if let Some(c) = it.command { // Settings ▸ opens Preferences on each page: one item per page instead of one. let params = if c == "edit.preferences" { String::new() } else { it.params.to_string() }; *n.entry((c.to_string(), params)).or_default() += 1; } } n }; let in_window = native_menu::from_tree(&app, &menus::menu_tree()); let mac = bar(&app); let (a, b) = (count(&in_window), count(&mac)); // The in-window application menu's commands, its submenus' included. let moved: Vec<&str> = MenuBar { menus: vec![in_window.menus[0].clone()] }.items().iter().filter_map(|it| it.command).collect(); for (key, n) in &a { let m = b.get(key).copied().unwrap_or(0); let want = match key.0.as_str() { "edit.preferences" => PREF_CATEGORIES.len(), c if moved.contains(&c) => 1, _ => *n, }; assert_eq!(m, want, "{key:?} is listed {m} times on the Mac, {n} in the window"); } let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE]; // Language and Appearance have no in-window menu (synthesized in the App menu, as in PhotoCraft). let synthesized = ["app.language", "window.brightness"]; for key in b.keys() { assert!(a.contains_key(key) || system.contains(&key.0.as_str()) || synthesized.contains(&key.0.as_str()), "{key:?} isn't an in-window item"); } let help = ids(menu(&mac, "Help")); assert!(help.contains(&"help.discord".to_string()) && !help.contains(&"help.about".to_string()), "{help:?}"); let edit = ids(menu(&mac, "Edit")); assert!(!edit.contains(&"edit.preferences".to_string()) && edit.contains(&"edit.keyboardShortcuts".to_string()), "{edit:?}"); assert!(ids(menu(&mac, "Window")).contains(&"[Workspace]".to_string())); } #[test] fn the_window_menu_has_the_system_items() { let bar = bar(&app(true)); let window = ids(&bar.menu(MenuRole::Window).unwrap().children); assert_eq!(window[..3], [native_menu::MINIMIZE.to_string(), "".into(), "---".into()]); assert_eq!(window[window.len() - 2..], ["---".to_string(), "".into()]); assert_eq!(window[3], "window.newWindow", "then VectorCraft's own items"); assert_eq!(find(&bar, native_menu::MINIMIZE).shortcut, Some("Ctrl+Cmd+M")); assert!(!window.iter().any(|i| i == "window.brightness"), "the theme is only in the app menu"); assert_eq!(Standard::Zoom.label(), "Zoom"); } /// A system key a command already has stays the command's: the system item goes without. The /// user's shortcuts show in the menus. #[test] fn a_command_keeps_a_system_key_it_is_bound_to() { let mut app = app(true); app.ui.shortcut_overrides.insert("view.grid".into(), "Ctrl+Cmd+H".into()); crate::shortcut_editor::sync(&app.ui); let layout = native_menu::mac_layout(&app, &native_menu::from_tree(&app, &menus::menu_tree()), Lang::EN); app.ui.shortcut_overrides.clear(); crate::shortcut_editor::sync(&app.ui); assert_eq!(find(&layout.bar, native_menu::HIDE).shortcut, None); assert_eq!(find(&layout.bar, "view.grid").shortcut, Some("Ctrl+Cmd+H"), "the user's override shows in the menu"); assert_eq!(layout.clashes.iter().map(|c| (c.shortcut, c.command)).collect::>(), [("Ctrl+Cmd+H", "view.grid")]); } /// A native menu can't turn a plain item into a check item in place: opening a document or /// selecting must not change the menus' structure, or every change rebuilds them. #[test] fn documents_and_selections_change_state_not_structure() { let empty = native_menu::structure_key(&bar(&app(false))); let mut a = app(true); assert_eq!(native_menu::structure_key(&bar(&a)), empty, "a document with a selected rectangle"); a.run("select.none", json!({})).unwrap(); a.run("text.create", json!({"x": 50, "y": 50, "text": "Hi"})).unwrap(); assert!(!a.session.active().unwrap().selection.objects.is_empty()); assert_eq!(native_menu::structure_key(&bar(&a)), empty, "type selected"); // A recent file is a new item: the menus are rebuilt. crate::io::note_recent(&mut a, "/art/a.svg"); assert_ne!(native_menu::structure_key(&bar(&a)), empty); } #[test] fn key_equivalents_are_command_and_function_keys_only() { let ok = |s| crate::shortcuts::parse(s).is_some_and(|k| native_menu::native_ok(&k)); assert!(ok("Cmd+B") && ok("F7") && ok("Ctrl+Cmd+M") && ok("Shift+F7")); assert!(!ok("B") && !ok("Alt+F") && !ok("Shift+X")); } /// What a key equivalent becomes: the press and release egui's window integration sends, or for /// ⌘X, ⌘C and ⌘V its clipboard events (a paste's release comes from the window). #[test] fn key_equivalents_become_the_input_their_keys_make() { let cmd = |key| KeyboardShortcut::new(Modifiers::COMMAND, key); let key = |key, pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND }; assert_eq!(native_menu::key_events(cmd(Key::A), || None), [key(Key::A, true), key(Key::A, false)]); assert_eq!(native_menu::key_events(cmd(Key::C), || None), [Event::Copy, key(Key::C, false)]); assert_eq!(native_menu::key_events(cmd(Key::X), || None), [Event::Cut, key(Key::X, false)]); assert_eq!(native_menu::key_events(cmd(Key::V), || Some("a\r\nb".into())), [Event::Paste("a\nb".into())]); assert_eq!(native_menu::key_events(cmd(Key::V), || None), [], "a picture: the release tells the paste"); } /// The fake backend: the menus the app synced, and events to report. #[derive(Clone, Default)] struct Fake(Rc, Vec)>>); impl Backend for Fake { fn sync(&mut self, bar: &MenuBar) { self.0.borrow_mut().0.push(bar.clone()); } fn drain(&mut self) -> Vec { std::mem::take(&mut self.0.borrow_mut().1) } } impl Fake { fn synced(&self) -> usize { self.0.borrow().0.len() } fn last(&self) -> MenuBar { self.0.borrow().0.last().cloned().unwrap() } fn report(&self, e: native_menu::Event) { self.0.borrow_mut().1.push(e); } } /// The app with the fake native menu (or none), driven a whole frame at a time like the desktop /// app. struct Mac { app: VectorcraftApp, fake: Fake, ctx: egui::Context, time: f64, } impl Mac { fn new(native: bool) -> Self { let mut app = app(true); let fake = Fake::default(); if native { app.services.native_menu = Some(NativeMenu::new(Box::new(fake.clone()))); } let mut m = Mac { app, fake, ctx: egui::Context::default(), time: 0.0 }; for _ in 0..3 { m.frame(vec![]); } m } fn frame(&mut self, events: Vec) -> egui::FullOutput { self.time += 0.1; let mut raw = egui::RawInput { events, time: Some(self.time), screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 850.0))), ..Default::default() }; self.app.raw_input_hook(&mut raw); let app = &mut self.app; let mut out = self.ctx.run_ui(raw, |ui| { app.logic(ui.ctx()); app.ui(ui); }); out.textures_delta.clear(); out } /// AppKit chose an item by its key equivalent `sc` (⌘ held). fn key_equivalent(&mut self, sc: &str) -> egui::FullOutput { let mut k = crate::shortcuts::parse(sc).unwrap(); k.modifiers.mac_cmd = k.modifiers.command; self.fake.report(native_menu::Event::Key(k)); self.frame(vec![]) } fn selected(&self) -> usize { self.app.session.active().unwrap().selection.objects.len() } /// Where the document's point (`x`, `y`) is on the screen. fn on_canvas(&self, x: f64, y: f64) -> Pos2 { crate::canvas::Xf::new(self.app.canvas_rect.unwrap(), self.app.view().unwrap()).to_screen(vectorcraft_geom::Point::new(x, y)) } /// Run `n` frames, each with the events that `step` returns (`step` may act on the app first), /// and return the number of frames in which the app read the menus. fn reads(&mut self, n: usize, mut step: impl FnMut(&mut Mac, usize) -> Vec) -> usize { let synced = self.fake.synced(); for i in 0..n { let events = step(self, i); self.frame(events); } self.fake.synced() - synced } /// Press on the selected rectangle and move the pointer 8 points to the right: the Selection /// tool's move begins. Returns where the press was. fn start_move(&mut self) -> Pos2 { let start = self.on_canvas(38.5, 40.0); self.frame(vec![Event::PointerMoved(start), click(start, true)]); self.frame(vec![Event::PointerMoved(start + vec2(8.0, 0.0))]); assert!(self.app.session.in_interaction(), "the move began"); start } /// Open the Rotate dialog with Preview on: the rotation previews on the canvas. fn rotate_preview(&mut self) { menus::invoke(&mut self.app, "object.rotate", json!({})); self.frame(vec![]); self.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true)); self.frame(vec![]); assert!(self.app.session.in_interaction(), "the rotation previews"); } /// Set `n` new angles in the open dialog through the control port, one a frame, and return the /// number of frames in which the app read the menus. fn angles(&mut self, n: usize) -> usize { self.reads(n, |m, i| { crate::tests_synthetic::control(&mut m.app, &m.ctx, "ui.dialog.set", json!({"field": "angle", "value": 10 * (i + 1)})); vec![] }) } } fn click(at: Pos2, pressed: bool) -> Event { Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } } /// Run `drag` on a new app with the fake native menu, and run it again on another new app until /// the shortcut and plug-in generations hold still through a run (at most 20 runs). Return that app /// and what `drag` returned. The generations are process-wide and only grow. A test running /// alongside can bump them, and the app then reads the menus again. fn undisturbed(drag: impl Fn(&mut Mac) -> R) -> (Mac, R) { let generations = || (crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed), crate::menus::plugin_revision()); for _ in 0..20 { let mut m = Mac::new(true); let before = generations(); let r = drag(&mut m); if generations() == before { return (m, r); } } panic!("tests running alongside kept bumping the generations"); } /// A key equivalent runs through the app's own key handling, once: ⌘A selects every object, ⌘Z /// undoes one step; and frames where nothing changed don't read the menus again. #[test] fn key_equivalents_run_through_the_normal_shortcut_path_once() { let mut m = Mac::new(true); m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap(); m.app.run("select.none", json!({})).unwrap(); m.frame(vec![]); m.key_equivalent("Cmd+A"); assert_eq!(m.selected(), 2, "⌘A selected every object"); let undo = |m: &Mac| m.app.session.active().unwrap().history.undo.len(); let before = undo(&m); m.key_equivalent("Cmd+Z"); assert_eq!(undo(&m), before - 1, "⌘Z undid one step"); assert!(m.fake.synced() >= 1, "the menus were built"); // Idle frames don't read the menus again. The shortcut and plug-in generations are // process-wide, and tests running alongside with other shortcuts bump them (rightly reading // the menus again), so judge two idle frames in which they held still. let globals = || (crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed), crate::menus::plugin_revision()); let quiet = (0..50).any(|_| { let (before, synced) = (globals(), m.fake.synced()); m.frame(vec![]); m.frame(vec![]); globals() == before && m.fake.synced() == synced }); assert!(quiet, "nothing changed: the menus weren't read again"); assert_eq!(crate::control::inspect(&m.app, &m.ctx)["nativeMenuBar"], json!(true)); } /// Each step of a drag previews the move as a new revision: the menus aren't read again for it /// while the button is held, and are read when the button is released, with Undo naming the move. #[test] fn a_drag_reads_the_menus_when_it_is_released() { let (mut m, (start, read)) = undisturbed(|m| { let start = m.start_move(); (start, m.reads(6, |_, i| vec![Event::PointerMoved(start + vec2(8.0 * (i + 2) as f32, 0.0))])) }); assert_eq!(read, 0, "the menus weren't read at any step of the drag"); let synced = m.fake.synced(); m.frame(vec![click(start + vec2(56.0, 0.0), false)]); assert!(!m.app.session.in_interaction()); assert_eq!(m.fake.synced(), synced + 1, "the menus were read on the release"); assert_eq!(find(&m.fake.last(), "edit.undo").label, "Undo Move"); } /// Escape ends a Rectangle tool drag while its button is still held: the menus are read then, with /// the preview's rectangle gone (nothing selected, so Cut is off), and not at the steps before. #[test] fn escape_during_a_drag_reads_the_menus() { let (mut m, read) = undisturbed(|m| { m.app.run("select.none", json!({})).unwrap(); m.app.run("tool.select", json!({"tool": "rectangle"})).unwrap(); let start = m.on_canvas(200.0, 150.0); m.frame(vec![Event::PointerMoved(start), click(start, true)]); m.frame(vec![Event::PointerMoved(start + vec2(10.0, 10.0))]); assert!(m.app.session.in_interaction(), "the rectangle is being drawn"); m.reads(6, |_, i| vec![Event::PointerMoved(start + vec2(10.0, 10.0) * (i + 2) as f32)]) }); assert_eq!(read, 0, "the menus weren't read at any step of the drag"); assert!(find(&m.fake.last(), "edit.cut").enabled, "the preview's rectangle is selected"); let synced = m.fake.synced(); m.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); assert!(!m.app.session.in_interaction(), "Escape dropped the rectangle"); assert_eq!(m.fake.synced(), synced + 1, "the menus were read"); assert!(!find(&m.fake.last(), "edit.cut").enabled, "nothing is selected"); } /// A move dragged off the canvas onto a panel stops being a move with its button still held /// (`canvas::drag_art_out`): the menus are read then, without a release or a key, and Undo still /// names the step before the drag. #[test] fn a_move_dragged_onto_a_panel_reads_the_menus() { let (mut m, (start, undo, read)) = undisturbed(|m| { let undo = find(&m.fake.last(), "edit.undo").label.clone(); let start = m.start_move(); (start, undo, m.reads(4, |_, i| vec![Event::PointerMoved(start + vec2(8.0 * (i + 2) as f32, 0.0))])) }); assert_eq!(read, 0, "the menus weren't read at any step of the drag"); let synced = m.fake.synced(); let panel = Pos2::new(m.app.canvas_rect.unwrap().right() + 40.0, start.y); m.frame(vec![Event::PointerMoved(panel)]); assert!(!m.app.session.in_interaction(), "the move was dropped"); assert_eq!(m.fake.synced(), synced + 1, "the menus were read"); assert_eq!(find(&m.fake.last(), "edit.undo").label, undo); } /// A dialog previews each angle that the control port sets, and the menus aren't read for the /// angles: `ui.dialog.cancel` on the control port ends the preview, and the menus are read then, /// without a release or a key. #[test] fn a_control_port_cancel_of_a_dialog_preview_reads_the_menus() { let (mut m, read) = undisturbed(|m| { m.rotate_preview(); m.angles(4) }); assert_eq!(read, 0, "the menus weren't read while the dialog previewed the angles"); let synced = m.fake.synced(); crate::tests_synthetic::control(&mut m.app, &m.ctx, "ui.dialog.cancel", json!({})); m.frame(vec![]); assert!(!m.app.session.in_interaction() && m.app.ui.dialog.is_none()); assert_eq!(m.fake.synced(), synced + 1, "the menus were read"); } /// OK applies the rotation a dialog previews as one undo step: the menus are read then, with Undo /// naming it, and not for the angles previewed before. #[test] fn applying_a_dialog_preview_reads_the_menus() { let (mut m, read) = undisturbed(|m| { m.rotate_preview(); m.angles(4) }); assert_eq!(read, 0, "the menus weren't read while the dialog previewed the angles"); let synced = m.fake.synced(); crate::tests_synthetic::control(&mut m.app, &m.ctx, "ui.dialog.confirm", json!({})); m.frame(vec![]); assert!(!m.app.session.in_interaction() && m.app.ui.dialog.is_none()); assert_eq!(m.fake.synced(), synced + 1, "the menus were read"); assert_eq!(find(&m.fake.last(), "edit.undo").label, "Undo Rotate"); } /// The font menu previews each font it highlights on the selected type, in one interaction: the /// menus are read as the preview begins and as it ends, and not for the fonts between. #[test] fn the_font_menu_preview_reads_the_menus_when_it_ends() { use crate::font_menu::FontPick; // The app reads the menus after each pick as at the end of a frame. A whole frame would start // with `font_menu::end_stale_preview`, which ends a preview whose menu isn't drawn. let apply = |m: &mut Mac, pick: FontPick| { let synced = m.fake.synced(); crate::font_menu::apply(&mut m.app, &m.ctx, pick); native_menu::sync(&mut m.app, &m.ctx); m.fake.synced() - synced }; let (mut m, read) = undisturbed(|m| { m.app.run("select.none", json!({})).unwrap(); m.app.run("text.create", json!({"x": 50, "y": 50, "text": "Hi"})).unwrap(); m.frame(vec![]); assert_eq!(apply(m, FontPick::Preview("Source Serif 4".into(), None)), 1, "the menus were read as the preview began"); apply(m, FontPick::Preview("Source Sans 3".into(), None)) + apply(m, FontPick::Preview("Inter".into(), None)) }); assert_eq!(read, 0, "the menus weren't read for the fonts after the first"); assert_eq!(apply(&mut m, FontPick::EndPreview), 1, "the menus were read as the preview ended"); assert!(!m.app.session.in_interaction()); } /// A click runs its command like an in-window click, and the menus follow: View › Show Rulers /// reads Hide Rulers afterwards; the app menu's Appearance items run with their params. #[test] fn clicks_run_their_command_and_the_menus_follow() { let mut m = Mac::new(true); let rulers = find(&m.fake.last(), "view.rulers").clone(); let before = m.app.ui.view.rulers; m.fake.report(native_menu::Event::Click(rulers.clone())); m.frame(vec![]); assert_eq!(m.app.ui.view.rulers, !before); assert_ne!(find(&m.fake.last(), "view.rulers").label, rulers.label, "the label flipped"); let other = m.fake.last().items().into_iter().find(|it| it.command == Some("window.brightness") && it.checked == Some(false)).cloned().unwrap(); m.fake.report(native_menu::Event::Click(other.clone())); m.frame(vec![]); assert_eq!(Some(m.app.ui.brightness.id()), other.params.get("brightness").and_then(Value::as_str)); let now = m.fake.last().items().into_iter().find(|it| it.params == other.params).cloned().unwrap(); assert_eq!(now.checked, Some(true), "the checkmark moved"); } /// With a text field focused, the menu's ⌘A and ⌘C act on the field, as the keys do without the /// native menu. #[test] fn a_focused_field_keeps_select_all_and_copy() { let mut m = Mac::new(true); let (id, rect) = m .ctx .viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).map(|w| (w.id, w.rect)).collect::>()) .into_iter() .find(|&(id, _)| m.ctx.data(|d| d.get_temp::(id)).is_some_and(|t| t == "37 pt") && egui::TextEdit::load_state(&m.ctx, id).is_some()) .expect("the W field"); let click = |pressed| Event::PointerButton { pos: rect.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; m.frame(vec![Event::PointerMoved(rect.center()), click(true)]); m.frame(vec![click(false)]); m.frame(vec![Event::Key { key: Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); assert!(m.ctx.text_edit_focused()); m.key_equivalent("Cmd+A"); let r = egui::TextEdit::load_state(&m.ctx, id).and_then(|s| s.cursor.char_range()).unwrap(); let (a, b): (usize, usize) = (r.primary.index.into(), r.secondary.index.into()); assert_eq!((a.min(b), a.max(b)), (0, 5), "⌘A selected the field's text"); assert_eq!(m.selected(), 1, "not the art"); let out = m.key_equivalent("Cmd+C"); let copied: Vec<_> = out.platform_output.commands.iter().filter_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t.as_str()) } else { None }).collect(); assert_eq!(copied, ["37 pt"]); assert!(m.app.session.clipboard.is_empty(), "no art copied"); } /// With the macOS menu bar the app bar draws no menu titles: each shows once fewer. #[test] fn the_app_bar_hides_its_menus_with_the_mac_menu_bar() { let texts = |native: bool| { let mut m = Mac::new(native); let out = m.frame(vec![]); out.shapes .iter() .filter_map(|s| if let egui::Shape::Text(t) = &s.shape { Some(t.galley.text().to_string()) } else { None }) .collect::>() }; let (in_window, native) = (texts(false), texts(true)); for (title, _) in menus::menu_tree().iter().skip(1) { let n = |t: &[String]| t.iter().filter(|s| s == title).count(); assert_eq!(n(&native) + 1, n(&in_window), "{title}"); } } /// While a file dialog's sheet is on the window the menu bar does nothing, as under the modal /// dialog before: every item is disabled but Hide, Hide Others and Minimize, a click that raced it /// isn't run (Minimize still is), and a key equivalent doesn't reach the app. Once the dialog /// answers, the menus are as before and work again. #[test] fn the_menu_bar_is_inert_while_a_file_dialog_is_open() { let mut m = Mac::new(true); m.app.services.dialogs_are_sheets = true; let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut m.app.services); m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap(); m.app.run("select.none", json!({})).unwrap(); m.frame(vec![]); let rows = |bar: &MenuBar| bar.items().iter().map(|it| (it.command, it.params.clone(), it.enabled)).collect::>(); let before = m.fake.last(); let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE]; assert!(system.iter().all(|c| find(&before, c).enabled)); assert!(m.app.run("file.open", json!({})).is_err()); assert!(m.app.file_dialog_open()); m.frame(vec![]); let inert = m.fake.last(); for it in inert.items() { let kept = it.command.is_some_and(|c| system.contains(&c)); assert_eq!(it.enabled, kept, "{:?} {} while the dialog is open", it.command, it.label); } // A click that raced the menu going inert isn't run; Minimize is. let rulers = (m.app.ui.view.rulers, find(&before, "view.rulers").clone()); m.fake.report(native_menu::Event::Click(rulers.1.clone())); m.frame(vec![]); assert_eq!(m.app.ui.view.rulers, rulers.0); m.fake.report(native_menu::Event::Click(find(&inert, native_menu::MINIMIZE).clone())); assert!(m.frame(vec![]).viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Minimized(true))); // A key equivalent that raced it doesn't reach egui. let mut k = crate::shortcuts::parse("Cmd+A").unwrap(); k.modifiers.mac_cmd = k.modifiers.command; m.fake.report(native_menu::Event::Key(k)); let mut raw = egui::RawInput::default(); m.app.raw_input_hook(&mut raw); assert!(!raw.events.iter().any(|e| matches!(e, Event::Key { .. })), "{:?}", raw.events); m.key_equivalent("Cmd+A"); assert_eq!(m.selected(), 0, "⌘A selected nothing"); // Cancelled: the menus are as before, and work again. shown.answer(&[]); m.frame(vec![]); m.frame(vec![]); assert!(!m.app.file_dialog_open()); // Every row from before is back as it was (tests running alongside may add plug-in rows). let after = rows(&m.fake.last()); assert!(rows(&before).iter().all(|r| after.contains(r)), "the menus are as before"); m.fake.report(native_menu::Event::Click(rulers.1)); m.frame(vec![]); assert_eq!(m.app.ui.view.rulers, !rulers.0); m.key_equivalent("Cmd+A"); assert_eq!(m.selected(), 2, "⌘A selected every object"); }