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);
+ }
+}