diff --git a/.isu/issues.json b/.isu/issues.json index a21f23e..b26fa7c 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -3018,7 +3018,7 @@ "labels": [], "assigned": [], "author": "piefev", - "state": "open", + "state": "closed", "created_at": "2026-05-21T19:22:06Z" }, { diff --git a/crates/browser/src/main.rs b/crates/browser/src/main.rs index 0392544..8cc158c 100644 --- a/crates/browser/src/main.rs +++ b/crates/browser/src/main.rs @@ -3170,6 +3170,71 @@ fn handle_scroll(_dx: f64, dy: f64, _mouse_x: f64, _mouse_y: f64) { }); } +// --------------------------------------------------------------------------- +// Menu bar action dispatch +// --------------------------------------------------------------------------- + +/// Called by AppKit when a custom menu item from the browser's menu bar +/// fires. The handler routes the action through the existing key-down +/// dispatch where possible so that menu-triggered and key-triggered +/// behavior stay identical. +fn handle_menu_action(action: we_platform::menu::MenuAction) { + use we_platform::menu::MenuAction; + + let cmd = appkit::KeyModifiers { + shift: false, + command: true, + option: false, + control: false, + }; + + match action { + MenuAction::OpenLocation => { + handle_key_down(we_browser::chrome::KEY_CODE_L, "l", cmd); + } + MenuAction::Reload => { + handle_key_down(we_browser::chrome::KEY_CODE_R, "r", cmd); + } + MenuAction::GoBack => { + handle_key_down(we_browser::chrome::KEY_CODE_LBRACKET, "[", cmd); + } + MenuAction::GoForward => { + handle_key_down(we_browser::chrome::KEY_CODE_RBRACKET, "]", cmd); + } + MenuAction::Cut => { + handle_key_down(KEY_CODE_X, "x", cmd); + } + MenuAction::Copy => { + handle_key_down(KEY_CODE_C, "c", cmd); + } + MenuAction::Paste => { + handle_key_down(KEY_CODE_V, "v", cmd); + } + MenuAction::SelectAll => { + handle_key_down(KEY_CODE_A, "a", cmd); + } + MenuAction::Stop => { + // Page loading is synchronous, so there is nothing in flight to cancel. + eprintln!("[we] Menu: Stop — no async load to cancel"); + } + MenuAction::NewWindow => { + eprintln!("[we] Menu: New Window — multi-window not implemented"); + } + MenuAction::Undo => { + eprintln!("[we] Menu: Undo — text-edit undo not implemented"); + } + MenuAction::Redo => { + eprintln!("[we] Menu: Redo — text-edit redo not implemented"); + } + MenuAction::ShowHelp => { + eprintln!("[we] Menu: Help — no help viewer yet"); + } + MenuAction::ZoomIn | MenuAction::ZoomOut | MenuAction::ActualSize => { + eprintln!("[we] Menu: page zoom not implemented"); + } + } +} + // --------------------------------------------------------------------------- // Page loading // --------------------------------------------------------------------------- @@ -3701,18 +3766,20 @@ fn load_page(loaded: LoadedHtml) -> PageState { // Fetch and decode images referenced by elements. let images = collect_images(&doc_ref, &mut loader, &loaded.base_url); - // Store decoded pixel data in the document so JS drawImage() can access it. - vm.with_document_mut(|doc| { - for (node_id, resource) in &images { - if let Some(img) = &resource.image { - doc.store_image_data(*node_id, img.data.clone(), img.width, img.height); - } - } - }); - (stylesheet, font_registry, images) }; + // Store decoded pixel data in the document so JS drawImage() can access it. + // Must happen after the immutable `doc_ref` borrow above is dropped, + // otherwise `with_document_mut` panics with "RefCell already borrowed". + vm.with_document_mut(|doc| { + for (node_id, resource) in &images { + if let Some(img) = &resource.image { + doc.store_image_data(*node_id, img.data.clone(), img.width, img.height); + } + } + }); + // All resources loaded: readyState → "complete", fire `load` on window. vm.set_ready_state("complete"); we_js::dom_bridge::fire_lifecycle_event( @@ -3774,6 +3841,17 @@ fn main() { app.set_activation_policy(appkit::NS_APPLICATION_ACTIVATION_POLICY_REGULAR); appkit::install_app_delegate(&app); + // Install the standard macOS menu bar (File / Edit / View / Window / Help) + // with native keyboard shortcuts. The returned target must outlive + // `app.run()`, so we leak it deliberately — it lives until process exit. + let (menu, menu_target) = we_platform::menu::build_browser_menu_bar("we"); + we_platform::menu::set_main_menu(&app, &menu); + we_platform::menu::set_menu_action_handler(handle_menu_action); + // Suppress the unused-variable warning while keeping the target rooted + // in `main`'s stack frame, which lives until the event loop exits. + let _menu_target = menu_target; + let _menu = menu; + let window = appkit::create_standard_window("we"); appkit::install_window_delegate(&window); window.set_accepts_mouse_moved_events(true); diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index f725a37..5590e05 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -3,5 +3,6 @@ pub mod appkit; pub mod cf; pub mod cg; +pub mod menu; pub mod metal; pub mod objc; diff --git a/crates/platform/src/menu.rs b/crates/platform/src/menu.rs new file mode 100644 index 0000000..329813d --- /dev/null +++ b/crates/platform/src/menu.rs @@ -0,0 +1,638 @@ +//! NSMenu / NSMenuItem FFI for building native macOS menu bars. +//! +//! Provides Rust wrappers around `NSMenu` and `NSMenuItem` plus a custom +//! Objective-C target class (`WeMenuTarget`) that dispatches user-defined +//! menu actions back into Rust through a global handler. +//! +//! # Safety +//! +//! This module contains `unsafe` code for FFI with AppKit. +//! The `platform` crate is one of the few crates where `unsafe` is permitted. + +use crate::appkit::App; +use crate::cf::CfString; +use crate::objc::{Class, Id, Imp, Sel}; +use crate::{class, msg_send}; +use std::ffi::CStr; +use std::os::raw::c_void; +use std::sync::Mutex; + +static CLASS_REGISTRATION_LOCK: Mutex<()> = Mutex::new(()); + +// --------------------------------------------------------------------------- +// Modifier mask constants — `NSEventModifierFlag*` shifted to the byte the +// menu item APIs expect. Pass these to [`MenuItem::set_key_equivalent_modifier_mask`]. +// --------------------------------------------------------------------------- + +/// Shift modifier (⇧). +pub const MENU_MODIFIER_SHIFT: u64 = 1 << 17; +/// Control modifier (⌃). +pub const MENU_MODIFIER_CONTROL: u64 = 1 << 18; +/// Option / Alt modifier (⌥). +pub const MENU_MODIFIER_OPTION: u64 = 1 << 19; +/// Command modifier (⌘) — the default for a single key equivalent. +pub const MENU_MODIFIER_COMMAND: u64 = 1 << 20; + +// --------------------------------------------------------------------------- +// MenuAction — identifies which custom menu item the user activated. +// --------------------------------------------------------------------------- + +/// Tags assigned to each custom menu item the browser cares about. +/// +/// Standard system actions (cut/copy/paste/quit/hide/close/minimize) go +/// through AppKit's responder chain and do not appear here. +#[repr(i64)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MenuAction { + NewWindow = 1, + OpenLocation = 2, + Reload = 3, + Stop = 4, + GoBack = 5, + GoForward = 6, + Undo = 7, + Redo = 8, + Cut = 9, + Copy = 10, + Paste = 11, + SelectAll = 12, + ShowHelp = 13, + ZoomIn = 14, + ZoomOut = 15, + ActualSize = 16, +} + +impl MenuAction { + /// Convert from an integer tag back to a [`MenuAction`]. + pub fn from_tag(tag: i64) -> Option { + Some(match tag { + 1 => MenuAction::NewWindow, + 2 => MenuAction::OpenLocation, + 3 => MenuAction::Reload, + 4 => MenuAction::Stop, + 5 => MenuAction::GoBack, + 6 => MenuAction::GoForward, + 7 => MenuAction::Undo, + 8 => MenuAction::Redo, + 9 => MenuAction::Cut, + 10 => MenuAction::Copy, + 11 => MenuAction::Paste, + 12 => MenuAction::SelectAll, + 13 => MenuAction::ShowHelp, + 14 => MenuAction::ZoomIn, + 15 => MenuAction::ZoomOut, + 16 => MenuAction::ActualSize, + _ => return None, + }) + } + + /// Integer tag value, suitable for [`MenuItem::set_tag`]. + pub fn tag(self) -> i64 { + self as i64 + } +} + +// --------------------------------------------------------------------------- +// Menu — wrapper around NSMenu +// --------------------------------------------------------------------------- + +/// Wrapper around `NSMenu`. +pub struct Menu { + menu: Id, +} + +impl Menu { + /// Create a new empty menu with the given title. + pub fn new(title: &str) -> Menu { + let cls = class!("NSMenu").expect("NSMenu class not found"); + let menu: *mut c_void = msg_send![cls.as_ptr(), alloc]; + let cf_title = CfString::new(title).expect("failed to create CFString for menu title"); + let menu: *mut c_void = msg_send![menu, initWithTitle: cf_title.as_void_ptr()]; + let menu = unsafe { Id::from_raw(menu as *mut _) }.expect("NSMenu init failed"); + + // Disable auto-enable so menu items track whatever enabled state we set. + let _: *mut c_void = msg_send![menu.as_ptr(), setAutoenablesItems: false]; + + Menu { menu } + } + + /// Append a menu item to this menu. + pub fn add_item(&self, item: &MenuItem) { + let _: *mut c_void = msg_send![self.menu.as_ptr(), addItem: item.item.as_ptr()]; + } + + /// Append a separator line to this menu. + pub fn add_separator(&self) { + let cls = class!("NSMenuItem").expect("NSMenuItem class not found"); + let sep: *mut c_void = msg_send![cls.as_ptr(), separatorItem]; + if !sep.is_null() { + let _: *mut c_void = msg_send![self.menu.as_ptr(), addItem: sep]; + } + } + + /// Set this menu as the submenu of a menu item. The item is typically a + /// top-level item on the main menu bar. + pub fn set_as_submenu_of(&self, item: &MenuItem) { + let _: *mut c_void = msg_send![item.id().as_ptr(), setSubmenu: self.menu.as_ptr()]; + } + + /// Return the underlying Objective-C object. + pub fn id(&self) -> Id { + self.menu + } +} + +// --------------------------------------------------------------------------- +// MenuItem — wrapper around NSMenuItem +// --------------------------------------------------------------------------- + +/// Wrapper around `NSMenuItem`. +pub struct MenuItem { + item: Id, +} + +impl MenuItem { + /// Create a menu item. + /// + /// * `title` — the displayed text. + /// * `action` — the selector to invoke when the item is selected + /// (e.g. `c"terminate:"`). Pass `None` for items that only host a + /// submenu, or for top-level menu bar items. + /// * `key_equivalent` — single-character key for the keyboard shortcut + /// (e.g. `"q"` for Cmd+Q, or `""` for no shortcut). Use lower-case for + /// the bare key; combine with [`MenuItem::set_key_equivalent_modifier_mask`] + /// to add modifiers other than Command. + pub fn new(title: &str, action: Option<&CStr>, key_equivalent: &str) -> MenuItem { + let cls = class!("NSMenuItem").expect("NSMenuItem class not found"); + let item: *mut c_void = msg_send![cls.as_ptr(), alloc]; + + let cf_title = CfString::new(title).expect("failed to create CFString for menu item title"); + let cf_key = + CfString::new(key_equivalent).expect("failed to create CFString for key equivalent"); + + let action_sel: *mut c_void = match action { + Some(sel_name) => Sel::register(sel_name).as_ptr() as *mut c_void, + None => std::ptr::null_mut(), + }; + + let item: *mut c_void = msg_send![ + item, + initWithTitle: cf_title.as_void_ptr(), + action: action_sel, + keyEquivalent: cf_key.as_void_ptr() + ]; + + let item = unsafe { Id::from_raw(item as *mut _) }.expect("NSMenuItem init failed"); + MenuItem { item } + } + + /// Set the key-equivalent modifier mask (a bitwise-OR of `MENU_MODIFIER_*`). + /// + /// By default, a key equivalent is interpreted as Command+key. To bind + /// e.g. Shift+Cmd+Z, pass `MENU_MODIFIER_COMMAND | MENU_MODIFIER_SHIFT`. + pub fn set_key_equivalent_modifier_mask(&self, mask: u64) { + let _: *mut c_void = msg_send![self.item.as_ptr(), setKeyEquivalentModifierMask: mask]; + } + + /// Set the target object that receives the action selector. + /// + /// If unset (or set to nil), AppKit walks the responder chain looking + /// for a target that responds to the action — useful for standard + /// selectors like `cut:`, `copy:`, `paste:`, `selectAll:`. + pub fn set_target(&self, target: &Id) { + let _: *mut c_void = msg_send![self.item.as_ptr(), setTarget: target.as_ptr()]; + } + + /// Set an integer tag used to identify this item from its action handler. + pub fn set_tag(&self, tag: i64) { + let _: *mut c_void = msg_send![self.item.as_ptr(), setTag: tag]; + } + + /// Enable or disable this menu item. + pub fn set_enabled(&self, enabled: bool) { + let _: *mut c_void = msg_send![self.item.as_ptr(), setEnabled: enabled]; + } + + /// Return the underlying Objective-C object. + pub fn id(&self) -> Id { + self.item + } +} + +// --------------------------------------------------------------------------- +// Custom NSObject subclass for menu action dispatch +// --------------------------------------------------------------------------- + +/// Global handler invoked when a [`MenuAction`] fires. +/// +/// # Safety +/// +/// Accessed only from the main thread (the AppKit event loop). +static mut MENU_ACTION_HANDLER: Option = None; + +/// Register the global handler called whenever a `WeMenuTarget` action +/// selector fires. Only one handler is supported. +pub fn set_menu_action_handler(handler: fn(MenuAction)) { + // SAFETY: Called from the main thread before `app.run()`. + unsafe { + MENU_ACTION_HANDLER = Some(handler); + } +} + +/// Selector exposed by `WeMenuTarget` for every custom menu item. +/// Reads `[sender tag]` to identify which item fired. +const MENU_ACTION_SELECTOR: &CStr = c"weMenuAction:"; + +/// Register the `WeMenuTarget` class if not already registered. +fn register_we_menu_target_class() { + if class!("WeMenuTarget").is_some() { + return; + } + + let _guard = CLASS_REGISTRATION_LOCK + .lock() + .expect("class registration lock poisoned"); + if class!("WeMenuTarget").is_some() { + return; + } + + let superclass = class!("NSObject").expect("NSObject not found"); + let target_class = Class::allocate(superclass, c"WeMenuTarget", 0) + .expect("failed to allocate WeMenuTarget class"); + + extern "C" fn menu_action(_this: *mut c_void, _sel: *mut c_void, sender: *mut c_void) { + if sender.is_null() { + return; + } + let tag: i64 = msg_send![sender, tag]; + if let Some(action) = MenuAction::from_tag(tag) { + // SAFETY: We are on the main thread (AppKit event loop). + unsafe { + if let Some(handler) = MENU_ACTION_HANDLER { + handler(action); + } + } + } + } + + let sel = Sel::register(MENU_ACTION_SELECTOR); + target_class.add_method( + sel, + unsafe { std::mem::transmute::<*const (), Imp>(menu_action as *const ()) }, + c"v@:@", + ); + + target_class.register(); +} + +/// Allocate a fresh instance of `WeMenuTarget` for use with +/// [`MenuItem::set_target`]. The same instance can be shared across many +/// menu items. +pub fn create_menu_target() -> Id { + register_we_menu_target_class(); + let cls = class!("WeMenuTarget").expect("WeMenuTarget class not found"); + let target: *mut c_void = msg_send![cls.as_ptr(), alloc]; + let target: *mut c_void = msg_send![target, init]; + unsafe { Id::from_raw(target as *mut _) }.expect("WeMenuTarget init failed") +} + +// --------------------------------------------------------------------------- +// Application main menu +// --------------------------------------------------------------------------- + +/// Set the application's main menu bar. +pub fn set_main_menu(app: &App, menu: &Menu) { + let _: *mut c_void = msg_send![app.id().as_ptr(), setMainMenu: menu.id().as_ptr()]; +} + +/// Build a standard browser menu bar with Application, File, Edit, View, +/// Window, and Help menus. Items wired to a custom selector use the +/// returned [`Id`] as their target; pair this with +/// [`set_menu_action_handler`] to react to selections. +pub fn build_browser_menu_bar(app_name: &str) -> (Menu, Id) { + let target = create_menu_target(); + + let main = Menu::new("MainMenu"); + + // Each top-level menu is added by attaching a submenu (the actual menu) + // to a placeholder menu item on the main bar. macOS displays the title + // of the *submenu*, not the placeholder, for every menu except the + // application menu (which always uses the running app's name). + + // -- Application menu -------------------------------------------------- + let app_item = MenuItem::new("", None, ""); + main.add_item(&app_item); + let app_menu = Menu::new(app_name); + { + let about = MenuItem::new( + &format!("About {app_name}"), + Some(c"orderFrontStandardAboutPanel:"), + "", + ); + app_menu.add_item(&about); + app_menu.add_separator(); + + let hide = MenuItem::new(&format!("Hide {app_name}"), Some(c"hide:"), "h"); + app_menu.add_item(&hide); + let hide_others = MenuItem::new("Hide Others", Some(c"hideOtherApplications:"), "h"); + hide_others.set_key_equivalent_modifier_mask(MENU_MODIFIER_COMMAND | MENU_MODIFIER_OPTION); + app_menu.add_item(&hide_others); + let show_all = MenuItem::new("Show All", Some(c"unhideAllApplications:"), ""); + app_menu.add_item(&show_all); + app_menu.add_separator(); + + let quit = MenuItem::new(&format!("Quit {app_name}"), Some(c"terminate:"), "q"); + app_menu.add_item(&quit); + } + app_menu.set_as_submenu_of(&app_item); + + // Helper to bind a custom action. + let bind = |item: &MenuItem, action: MenuAction| { + item.set_target(&target); + item.set_tag(action.tag()); + }; + + // -- File menu --------------------------------------------------------- + let file_item = MenuItem::new("File", None, ""); + main.add_item(&file_item); + let file_menu = Menu::new("File"); + { + let new_window = MenuItem::new("New Window", Some(MENU_ACTION_SELECTOR), "n"); + bind(&new_window, MenuAction::NewWindow); + file_menu.add_item(&new_window); + + let open_location = MenuItem::new("Open Location\u{2026}", Some(MENU_ACTION_SELECTOR), "l"); + bind(&open_location, MenuAction::OpenLocation); + file_menu.add_item(&open_location); + + file_menu.add_separator(); + + let close = MenuItem::new("Close Window", Some(c"performClose:"), "w"); + file_menu.add_item(&close); + } + file_menu.set_as_submenu_of(&file_item); + + // -- Edit menu --------------------------------------------------------- + let edit_item = MenuItem::new("Edit", None, ""); + main.add_item(&edit_item); + let edit_menu = Menu::new("Edit"); + { + let undo = MenuItem::new("Undo", Some(MENU_ACTION_SELECTOR), "z"); + bind(&undo, MenuAction::Undo); + edit_menu.add_item(&undo); + + let redo = MenuItem::new("Redo", Some(MENU_ACTION_SELECTOR), "z"); + redo.set_key_equivalent_modifier_mask(MENU_MODIFIER_COMMAND | MENU_MODIFIER_SHIFT); + bind(&redo, MenuAction::Redo); + edit_menu.add_item(&redo); + + edit_menu.add_separator(); + + let cut = MenuItem::new("Cut", Some(MENU_ACTION_SELECTOR), "x"); + bind(&cut, MenuAction::Cut); + edit_menu.add_item(&cut); + + let copy = MenuItem::new("Copy", Some(MENU_ACTION_SELECTOR), "c"); + bind(©, MenuAction::Copy); + edit_menu.add_item(©); + + let paste = MenuItem::new("Paste", Some(MENU_ACTION_SELECTOR), "v"); + bind(&paste, MenuAction::Paste); + edit_menu.add_item(&paste); + + edit_menu.add_separator(); + + let select_all = MenuItem::new("Select All", Some(MENU_ACTION_SELECTOR), "a"); + bind(&select_all, MenuAction::SelectAll); + edit_menu.add_item(&select_all); + } + edit_menu.set_as_submenu_of(&edit_item); + + // -- View menu --------------------------------------------------------- + let view_item = MenuItem::new("View", None, ""); + main.add_item(&view_item); + let view_menu = Menu::new("View"); + { + let reload = MenuItem::new("Reload Page", Some(MENU_ACTION_SELECTOR), "r"); + bind(&reload, MenuAction::Reload); + view_menu.add_item(&reload); + + let stop = MenuItem::new("Stop", Some(MENU_ACTION_SELECTOR), "."); + bind(&stop, MenuAction::Stop); + view_menu.add_item(&stop); + + view_menu.add_separator(); + + let back = MenuItem::new("Back", Some(MENU_ACTION_SELECTOR), "["); + bind(&back, MenuAction::GoBack); + view_menu.add_item(&back); + + let forward = MenuItem::new("Forward", Some(MENU_ACTION_SELECTOR), "]"); + bind(&forward, MenuAction::GoForward); + view_menu.add_item(&forward); + + view_menu.add_separator(); + + let actual = MenuItem::new("Actual Size", Some(MENU_ACTION_SELECTOR), "0"); + bind(&actual, MenuAction::ActualSize); + view_menu.add_item(&actual); + + let zoom_in = MenuItem::new("Zoom In", Some(MENU_ACTION_SELECTOR), "="); + bind(&zoom_in, MenuAction::ZoomIn); + view_menu.add_item(&zoom_in); + + let zoom_out = MenuItem::new("Zoom Out", Some(MENU_ACTION_SELECTOR), "-"); + bind(&zoom_out, MenuAction::ZoomOut); + view_menu.add_item(&zoom_out); + } + view_menu.set_as_submenu_of(&view_item); + + // -- Window menu ------------------------------------------------------- + let window_item = MenuItem::new("Window", None, ""); + main.add_item(&window_item); + let window_menu = Menu::new("Window"); + { + let minimize = MenuItem::new("Minimize", Some(c"performMiniaturize:"), "m"); + window_menu.add_item(&minimize); + + let zoom = MenuItem::new("Zoom", Some(c"performZoom:"), ""); + window_menu.add_item(&zoom); + + window_menu.add_separator(); + + let bring_all = MenuItem::new("Bring All to Front", Some(c"arrangeInFront:"), ""); + window_menu.add_item(&bring_all); + } + window_menu.set_as_submenu_of(&window_item); + // Tell AppKit this is the Window menu so it tracks open windows under it. + let cls = class!("NSApplication").expect("NSApplication class not found"); + let shared: *mut c_void = msg_send![cls.as_ptr(), sharedApplication]; + if !shared.is_null() { + let _: *mut c_void = msg_send![shared, setWindowsMenu: window_menu.id().as_ptr()]; + } + + // -- Help menu --------------------------------------------------------- + let help_item = MenuItem::new("Help", None, ""); + main.add_item(&help_item); + let help_menu = Menu::new("Help"); + { + // ⌘? is typed as Shift+Cmd+/, but the key equivalent is "?" and the + // modifier mask must include Shift for macOS to match the chord. + let help = MenuItem::new(&format!("{app_name} Help"), Some(MENU_ACTION_SELECTOR), "?"); + help.set_key_equivalent_modifier_mask(MENU_MODIFIER_COMMAND | MENU_MODIFIER_SHIFT); + bind(&help, MenuAction::ShowHelp); + help_menu.add_item(&help); + } + help_menu.set_as_submenu_of(&help_item); + if !shared.is_null() { + let _: *mut c_void = msg_send![shared, setHelpMenu: help_menu.id().as_ptr()]; + } + + (main, target) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::appkit::AutoreleasePool; + + #[test] + fn menu_action_round_trip() { + for tag in 1..=16i64 { + let action = MenuAction::from_tag(tag).expect("known tag"); + assert_eq!(action.tag(), tag); + } + assert!(MenuAction::from_tag(0).is_none()); + assert!(MenuAction::from_tag(99).is_none()); + } + + #[test] + fn menu_action_unique_tags() { + // Sanity-check that the discriminants in `MenuAction` are all distinct. + let actions = [ + MenuAction::NewWindow, + MenuAction::OpenLocation, + MenuAction::Reload, + MenuAction::Stop, + MenuAction::GoBack, + MenuAction::GoForward, + MenuAction::Undo, + MenuAction::Redo, + MenuAction::Cut, + MenuAction::Copy, + MenuAction::Paste, + MenuAction::SelectAll, + MenuAction::ShowHelp, + MenuAction::ZoomIn, + MenuAction::ZoomOut, + MenuAction::ActualSize, + ]; + for (i, &a) in actions.iter().enumerate() { + for &b in &actions[i + 1..] { + assert_ne!(a.tag(), b.tag(), "{:?} and {:?} share a tag", a, b); + } + } + } + + #[test] + fn menu_can_be_constructed() { + let _pool = AutoreleasePool::new(); + let menu = Menu::new("Test"); + assert!(!menu.id().as_ptr().is_null()); + } + + #[test] + fn menu_item_can_be_constructed() { + let _pool = AutoreleasePool::new(); + let item = MenuItem::new("Hello", Some(c"terminate:"), "q"); + assert!(!item.id().as_ptr().is_null()); + } + + #[test] + fn menu_item_without_action() { + let _pool = AutoreleasePool::new(); + let item = MenuItem::new("Top", None, ""); + assert!(!item.id().as_ptr().is_null()); + } + + #[test] + fn menu_add_item_and_separator() { + let _pool = AutoreleasePool::new(); + let menu = Menu::new("Test"); + let item = MenuItem::new("One", None, ""); + menu.add_item(&item); + menu.add_separator(); + // numberOfItems should reflect the two adds. + let count: i64 = msg_send![menu.id().as_ptr(), numberOfItems]; + assert_eq!(count, 2); + } + + #[test] + fn submenu_attachment() { + let _pool = AutoreleasePool::new(); + let outer = Menu::new("Outer"); + let placeholder = MenuItem::new("Inner", None, ""); + outer.add_item(&placeholder); + + let inner = Menu::new("Inner"); + inner.set_as_submenu_of(&placeholder); + + let attached: *mut c_void = msg_send![placeholder.id().as_ptr(), submenu]; + assert_eq!(attached, inner.id().as_ptr() as *mut c_void); + } + + #[test] + fn we_menu_target_class_registers() { + register_we_menu_target_class(); + let cls = class!("WeMenuTarget"); + assert!(cls.is_some()); + } + + #[test] + fn we_menu_target_responds_to_action() { + register_we_menu_target_class(); + let cls = class!("WeMenuTarget").expect("registered"); + let sel = Sel::register(MENU_ACTION_SELECTOR); + let responds: bool = msg_send![cls.as_ptr(), instancesRespondToSelector: sel.as_ptr()]; + assert!(responds); + } + + #[test] + fn create_menu_target_returns_instance() { + let _pool = AutoreleasePool::new(); + let target = create_menu_target(); + assert!(!target.as_ptr().is_null()); + } + + #[test] + fn menu_item_tag_round_trip() { + let _pool = AutoreleasePool::new(); + let item = MenuItem::new("Tag", Some(c"weMenuAction:"), ""); + item.set_tag(42); + let tag: i64 = msg_send![item.id().as_ptr(), tag]; + assert_eq!(tag, 42); + } + + #[test] + fn menu_item_modifier_mask_round_trip() { + let _pool = AutoreleasePool::new(); + let item = MenuItem::new("Mod", Some(c"weMenuAction:"), "z"); + let mask = MENU_MODIFIER_COMMAND | MENU_MODIFIER_SHIFT; + item.set_key_equivalent_modifier_mask(mask); + let stored: u64 = msg_send![item.id().as_ptr(), keyEquivalentModifierMask]; + assert_eq!(stored, mask); + } + + #[test] + fn build_browser_menu_bar_basic_shape() { + let _pool = AutoreleasePool::new(); + let (bar, _target) = build_browser_menu_bar("we"); + // Expect 6 top-level items: app/File/Edit/View/Window/Help. + let count: i64 = msg_send![bar.id().as_ptr(), numberOfItems]; + assert_eq!(count, 6); + } +}