diff --git a/examples/custom_menu.rs b/examples/custom_menu.rs new file mode 100644 index 0000000000..ba515988a3 --- /dev/null +++ b/examples/custom_menu.rs @@ -0,0 +1,82 @@ +use simple_logger::SimpleLogger; +use winit::{ + event::{Event, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + menu::{Menu, MenuItem}, + window::WindowBuilder, +}; + +pub enum Message { + MenuClickAddNewItem, +} + +fn main() { + SimpleLogger::new().init().unwrap(); + let event_loop = EventLoop::new(); + + let window = WindowBuilder::new() + .with_title("A fantastic window!") + .with_menu(vec![ + Menu::new( + "", + vec![ + MenuItem::About("Todos".to_string()), + MenuItem::Separator, + MenuItem::new("preferences".to_string(), "Preferences".to_string()).key(","), + MenuItem::Separator, + MenuItem::Services, + MenuItem::Separator, + MenuItem::Hide, + MenuItem::HideOthers, + MenuItem::ShowAll, + MenuItem::Separator, + MenuItem::Quit, + ], + ), + Menu::new( + "Custom menu", + vec![ + MenuItem::new("add_todo".to_string(), "Add Todo".to_string()).key("+"), + MenuItem::Separator, + MenuItem::CloseWindow, + ], + ), + Menu::new( + "Edit", + vec![ + MenuItem::Undo, + MenuItem::Redo, + MenuItem::Separator, + MenuItem::Cut, + MenuItem::Copy, + MenuItem::Paste, + MenuItem::Separator, + MenuItem::SelectAll, + ], + ), + Menu::new("View", vec![MenuItem::EnterFullScreen]), + Menu::new("Window", vec![MenuItem::Minimize, MenuItem::Zoom]), + Menu::new("Help", vec![]), + ]) + .build(&event_loop) + .unwrap(); + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::WindowEvent { + event: WindowEvent::CloseRequested, + window_id, + } if window_id == window.id() => *control_flow = ControlFlow::Exit, + Event::MainEventsCleared => { + window.request_redraw(); + } + Event::MenuEvent(menu_id) => { + println!("Clicked on {}", menu_id); + window.set_title("New window title!"); + } + _ => (), + } + }); +} diff --git a/examples/custom_menu_multiwindow.rs b/examples/custom_menu_multiwindow.rs new file mode 100644 index 0000000000..4706b3c871 --- /dev/null +++ b/examples/custom_menu_multiwindow.rs @@ -0,0 +1,64 @@ +use std::collections::HashMap; + +use simple_logger::SimpleLogger; +use winit::{ + event::{ElementState, Event, KeyboardInput, WindowEvent}, + event_loop::{ControlFlow, EventLoop}, + menu::{Menu, MenuItem}, + window::{Window, WindowBuilder}, +}; + +fn main() { + SimpleLogger::new().init().unwrap(); + let event_loop = EventLoop::new(); + + let mut windows = HashMap::new(); + for _ in 0..3 { + let window = WindowBuilder::new() + .with_menu(vec![Menu::new( + "asf", + vec![ + MenuItem::About("My app!".to_string()), + MenuItem::Separator, + MenuItem::ShowAll, + ], + )]) + .build(&event_loop) + .unwrap(); + windows.insert(window.id(), window); + } + + event_loop.run(move |event, event_loop, control_flow| { + *control_flow = ControlFlow::Wait; + + match event { + Event::WindowEvent { event, window_id } => { + match event { + WindowEvent::CloseRequested => { + println!("Window {:?} has received the signal to close", window_id); + + // This drops the window, causing it to close. + windows.remove(&window_id); + + if windows.is_empty() { + *control_flow = ControlFlow::Exit; + } + } + WindowEvent::KeyboardInput { + input: + KeyboardInput { + state: ElementState::Pressed, + .. + }, + .. + } => { + let window = Window::new(&event_loop).unwrap(); + windows.insert(window.id(), window); + } + _ => (), + } + } + _ => (), + } + }) +} diff --git a/src/event.rs b/src/event.rs index f39ddaac50..63df286f3c 100644 --- a/src/event.rs +++ b/src/event.rs @@ -70,6 +70,9 @@ pub enum Event<'a, T: 'static> { /// Emitted when an event is sent from [`EventLoopProxy::send_event`](crate::event_loop::EventLoopProxy::send_event) UserEvent(T), + /// Emitted when a menu has been clicked. + MenuEvent(String), + /// Emitted when the application has been suspended. Suspended, @@ -138,6 +141,7 @@ impl Clone for Event<'static, T> { LoopDestroyed => LoopDestroyed, Suspended => Suspended, Resumed => Resumed, + MenuEvent(event) => MenuEvent(event.clone()), } } } @@ -156,6 +160,7 @@ impl<'a, T> Event<'a, T> { LoopDestroyed => Ok(LoopDestroyed), Suspended => Ok(Suspended), Resumed => Ok(Resumed), + MenuEvent(event) => Ok(MenuEvent(event)), } } @@ -176,6 +181,7 @@ impl<'a, T> Event<'a, T> { LoopDestroyed => Some(LoopDestroyed), Suspended => Some(Suspended), Resumed => Some(Resumed), + MenuEvent(event) => Some(MenuEvent(event)), } } } diff --git a/src/lib.rs b/src/lib.rs index 51f4a8634a..f5360d0519 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -155,6 +155,7 @@ pub mod error; pub mod event; pub mod event_loop; mod icon; +pub mod menu; pub mod monitor; mod platform_impl; pub mod window; diff --git a/src/menu.rs b/src/menu.rs new file mode 100644 index 0000000000..6239668131 --- /dev/null +++ b/src/menu.rs @@ -0,0 +1,104 @@ +#[derive(Debug, Clone)] +pub struct Menu { + pub title: String, + pub items: Vec, +} + +impl Menu { + pub fn new(title: &str, items: Vec) -> Self { + Self { + title: String::from(title), + items, + } + } +} + +#[derive(Debug, Clone)] +pub struct CustomMenu { + pub id: String, + pub name: String, + pub key: Option, +} + +#[derive(Debug, Clone)] +pub enum MenuItem { + /// A custom MenuItem. This type functions as a builder, so you can customize it easier. + /// You can (and should) create this variant via the `new(title)` method, but if you need to do + /// something crazier, then wrap it in this and you can hook into the Cacao menu system + /// accordingly. + Custom(CustomMenu), + + /// Shows a standard "About" item, which will bring up the necessary window when clicked + /// (include a `credits.html` in your App to make use of here). The argument baked in here + /// should be your app name. + About(String), + + /// A standard "hide the app" menu item. + Hide, + + /// A standard "Services" menu item. + Services, + + /// A "hide all other windows" menu item. + HideOthers, + + /// A menu item to show all the windows for this app. + ShowAll, + + /// Close the current window. + CloseWindow, + + /// A "quit this app" menu icon. + Quit, + + /// A menu item for enabling copying (often text) from responders. + Copy, + + /// A menu item for enabling cutting (often text) from responders. + Cut, + + /// An "undo" menu item; particularly useful for supporting the cut/copy/paste/undo lifecycle + /// of events. + Undo, + + /// An "redo" menu item; particularly useful for supporting the cut/copy/paste/undo lifecycle + /// of events. + Redo, + + /// A menu item for selecting all (often text) from responders. + SelectAll, + + /// A menu item for pasting (often text) into responders. + Paste, + + /// A standard "enter full screen" item. + EnterFullScreen, + + /// An item for minimizing the window with the standard system controls. + Minimize, + + /// An item for instructing the app to zoom. Your app must react to this with necessary window + /// lifecycle events. + Zoom, + + /// Represents a Separator. It's useful nonetheless for + /// separating out pieces of the `NSMenu` structure. + Separator, +} + +impl MenuItem { + pub fn new(unique_menu_id: String, title: String) -> Self { + MenuItem::Custom(CustomMenu { + id: unique_menu_id, + key: None, + name: title, + }) + } + + pub fn key(mut self, key: &str) -> Self { + if let MenuItem::Custom(ref mut custom_menu) = self { + custom_menu.key = Some(key.to_string()); + } + self + } +} diff --git a/src/platform_impl/macos/app_state.rs b/src/platform_impl/macos/app_state.rs index 4f20e21694..59cf0f2aac 100644 --- a/src/platform_impl/macos/app_state.rs +++ b/src/platform_impl/macos/app_state.rs @@ -25,7 +25,6 @@ use crate::{ platform_impl::platform::{ event::{EventProxy, EventWrapper}, event_loop::{post_dummy_event, PanicInfo}, - menu, observer::{CFRunLoopGetMain, CFRunLoopWakeUp, EventLoopWaker}, util::{IdRef, Never}, window::get_window_id, @@ -275,9 +274,6 @@ impl AppState { pub fn launched() { HANDLER.set_ready(); HANDLER.waker().start(); - // The menubar initialization should be before the `NewEvents` event, to allow overriding - // of the default menu in the event - menu::initialize(); HANDLER.set_in_callback(true); HANDLER.handle_nonuser_event(EventWrapper::StaticEvent(Event::NewEvents( StartCause::Init, diff --git a/src/platform_impl/macos/menu.rs b/src/platform_impl/macos/menu.rs index a0d61edd74..8b13bc62ff 100644 --- a/src/platform_impl/macos/menu.rs +++ b/src/platform_impl/macos/menu.rs @@ -1,105 +1,223 @@ -use cocoa::appkit::{ - NSApp, NSApplication, NSApplicationActivationPolicyRegular, NSEventModifierFlags, NSMenu, - NSMenuItem, -}; -use cocoa::base::{nil, selector}; -use cocoa::foundation::{NSAutoreleasePool, NSProcessInfo, NSString}; +use cocoa::appkit::{NSApp, NSApplication, NSEventModifierFlags, NSMenu, NSMenuItem}; +use cocoa::base::{id, nil, selector}; +use cocoa::foundation::{NSAutoreleasePool, NSString}; use objc::{ + declare::ClassDecl, rc::autoreleasepool, - runtime::{Object, Sel}, + runtime::{Class, Object, Sel}, }; +use std::sync::Once; + +use crate::event::Event; +use crate::menu::{Menu, MenuItem}; + +use super::{app_state::AppState, event::EventWrapper}; + +static BLOCK_PTR: &'static str = "winitMenuItemBlockPtr"; struct KeyEquivalent<'a> { key: &'a str, masks: Option, } +#[derive(Debug)] +struct Action(Box); -pub fn initialize() { +pub fn initialize(menu: Vec) { autoreleasepool(|| unsafe { let menubar = NSMenu::new(nil).autorelease(); - let app_menu_item = NSMenuItem::new(nil).autorelease(); - menubar.addItem_(app_menu_item); - let app = NSApp(); - app.setMainMenu_(menubar); - let app_menu = NSMenu::new(nil); - let process_name = NSProcessInfo::processInfo(nil).processName(); + for menu in menu { + // create our menu + let menu_item = NSMenuItem::new(nil).autorelease(); + menubar.addItem_(menu_item); + // prepare our submenu tree + let menu_title = NSString::alloc(nil).init_str(&menu.title); + let menu_object = NSMenu::alloc(nil).initWithTitle_(menu_title).autorelease(); - // About menu item - let about_item_prefix = NSString::alloc(nil).init_str("About "); - let about_item_title = about_item_prefix.stringByAppendingString_(process_name); - let about_item = menu_item( - about_item_title, - selector("orderFrontStandardAboutPanel:"), - None, - ); + // create menu + for item in &menu.items { + let item_obj: *mut Object = match item { + MenuItem::Custom(custom_menu) => make_custom_menu_item( + custom_menu.id.clone(), + custom_menu.name.as_str(), + None, + None, + ), + MenuItem::Separator => NSMenuItem::separatorItem(nil), + MenuItem::About(app_name) => { + let title = format!("About {}", app_name); + make_menu_item( + title.as_str(), + Some(selector("orderFrontStandardAboutPanel:")), + None, + ) + } + MenuItem::CloseWindow => make_menu_item( + "Close Window", + Some(selector("performClose:")), + Some(KeyEquivalent { + key: "w", + masks: None, + }), + ), + MenuItem::Quit => make_menu_item( + "Quit", + Some(selector("terminate:")), + Some(KeyEquivalent { + key: "q", + masks: None, + }), + ), + MenuItem::Hide => make_menu_item( + "Hide", + Some(selector("hide:")), + Some(KeyEquivalent { + key: "h", + masks: None, + }), + ), + MenuItem::HideOthers => make_menu_item( + "Hide Others", + Some(selector("hideOtherApplications:")), + Some(KeyEquivalent { + key: "h", + masks: Some( + NSEventModifierFlags::NSAlternateKeyMask + | NSEventModifierFlags::NSCommandKeyMask, + ), + }), + ), + MenuItem::ShowAll => { + make_menu_item("Show All", Some(selector("unhideAllApplications:")), None) + } + MenuItem::EnterFullScreen => make_menu_item( + "Enter Full Screen", + Some(selector("toggleFullScreen:")), + Some(KeyEquivalent { + key: "h", + masks: Some( + NSEventModifierFlags::NSCommandKeyMask + | NSEventModifierFlags::NSControlKeyMask, + ), + }), + ), + MenuItem::Minimize => make_menu_item( + "Minimize", + Some(selector("performMiniaturize:")), + Some(KeyEquivalent { + key: "m", + masks: None, + }), + ), + MenuItem::Zoom => make_menu_item("Zoom", Some(selector("performZoom:")), None), + MenuItem::Copy => make_menu_item( + "Copy", + Some(selector("copy:")), + Some(KeyEquivalent { + key: "c", + masks: None, + }), + ), + MenuItem::Cut => make_menu_item( + "Cut", + Some(selector("cut:")), + Some(KeyEquivalent { + key: "x", + masks: None, + }), + ), + MenuItem::Paste => make_menu_item( + "Paste", + Some(selector("paste:")), + Some(KeyEquivalent { + key: "v", + masks: None, + }), + ), + MenuItem::Undo => make_menu_item( + "Undo", + Some(selector("undo:")), + Some(KeyEquivalent { + key: "z", + masks: None, + }), + ), + MenuItem::Redo => make_menu_item( + "Redo", + Some(selector("redo:")), + Some(KeyEquivalent { + key: "Z", + masks: None, + }), + ), + MenuItem::SelectAll => make_menu_item( + "Select All", + Some(selector("selectAll:")), + Some(KeyEquivalent { + key: "a", + masks: None, + }), + ), + MenuItem::Services => { + let item = make_menu_item("Services", None, None); + let app_class = class!(NSApplication); + let app: id = msg_send![app_class, sharedApplication]; + let services: id = msg_send![app, servicesMenu]; + let _: () = msg_send![&*item, setSubmenu: services]; + item + } + }; - // Seperator menu item - let sep_first = NSMenuItem::separatorItem(nil); - - // Hide application menu item - let hide_item_prefix = NSString::alloc(nil).init_str("Hide "); - let hide_item_title = hide_item_prefix.stringByAppendingString_(process_name); - let hide_item = menu_item( - hide_item_title, - selector("hide:"), - Some(KeyEquivalent { - key: "h", - masks: None, - }), - ); + menu_object.addItem_(item_obj); + } - // Hide other applications menu item - let hide_others_item_title = NSString::alloc(nil).init_str("Hide Others"); - let hide_others_item = menu_item( - hide_others_item_title, - selector("hideOtherApplications:"), - Some(KeyEquivalent { - key: "h", - masks: Some( - NSEventModifierFlags::NSAlternateKeyMask - | NSEventModifierFlags::NSCommandKeyMask, - ), - }), - ); + menu_item.setSubmenu_(menu_object); + } - // Show applications menu item - let show_all_item_title = NSString::alloc(nil).init_str("Show All"); - let show_all_item = menu_item( - show_all_item_title, - selector("unhideAllApplications:"), - None, - ); + // Set the menu as main menu for the app + let app = NSApp(); + app.setMainMenu_(menubar); + }); +} - // Seperator menu item - let sep = NSMenuItem::separatorItem(nil); - - // Quit application menu item - let quit_item_prefix = NSString::alloc(nil).init_str("Quit "); - let quit_item_title = quit_item_prefix.stringByAppendingString_(process_name); - let quit_item = menu_item( - quit_item_title, - selector("terminate:"), - Some(KeyEquivalent { - key: "q", - masks: None, - }), - ); +fn make_menu_alloc() -> *mut Object { + unsafe { msg_send![make_menu_item_class(), alloc] } +} - app_menu.addItem_(about_item); - app_menu.addItem_(sep_first); - app_menu.addItem_(hide_item); - app_menu.addItem_(hide_others_item); - app_menu.addItem_(show_all_item); - app_menu.addItem_(sep); - app_menu.addItem_(quit_item); - app_menu_item.setSubmenu_(app_menu); - }); +fn make_custom_menu_item( + id: String, + title: &str, + selector: Option, + key_equivalent: Option>, +) -> *mut Object { + let alloc = make_menu_alloc(); + let menu_id = Box::new(Action(Box::new(id))); + let ptr = Box::into_raw(menu_id); + + unsafe { + (&mut *alloc).set_ivar(BLOCK_PTR, ptr as usize); + let _: () = msg_send![&*alloc, setTarget:&*alloc]; + let title = NSString::alloc(nil).init_str(title); + make_menu_item_from_alloc(alloc, title, selector, key_equivalent) + } } -fn menu_item( +fn make_menu_item( + title: &str, + selector: Option, + key_equivalent: Option>, +) -> *mut Object { + let alloc = make_menu_alloc(); + unsafe { + let title = NSString::alloc(nil).init_str(title); + make_menu_item_from_alloc(alloc, title, selector, key_equivalent) + } +} + +fn make_menu_item_from_alloc( + alloc: *mut Object, title: *mut Object, - selector: Sel, + selector: Option, key_equivalent: Option>, ) -> *mut Object { unsafe { @@ -107,7 +225,15 @@ fn menu_item( Some(ke) => (NSString::alloc(nil).init_str(ke.key), ke.masks), None => (NSString::alloc(nil).init_str(""), None), }; - let item = NSMenuItem::alloc(nil).initWithTitle_action_keyEquivalent_(title, selector, key); + // if no selector defined, that mean it's a custom + // menu so fire our handler + let selector = match selector { + Some(selector) => selector, + None => sel!(fireCustomMenuAction:), + }; + + // allocate our item to our class + let item: id = msg_send![alloc, initWithTitle:&*title action:selector keyEquivalent:&*key]; if let Some(masks) = masks { item.setKeyEquivalentModifierMask_(masks) } @@ -115,3 +241,48 @@ fn menu_item( item } } + +fn make_menu_item_class() -> *const Class { + static mut APP_CLASS: *const Class = 0 as *const Class; + static INIT: Once = Once::new(); + + INIT.call_once(|| unsafe { + let superclass = class!(NSMenuItem); + let mut decl = ClassDecl::new("WinitMenuItem", superclass).unwrap(); + decl.add_ivar::(BLOCK_PTR); + + decl.add_method( + sel!(dealloc), + dealloc_custom_menuitem as extern "C" fn(&Object, _), + ); + decl.add_method( + sel!(fireCustomMenuAction:), + fire_custom_menu_click as extern "C" fn(&Object, _, id), + ); + + APP_CLASS = decl.register(); + }); + + unsafe { APP_CLASS } +} + +extern "C" fn fire_custom_menu_click(this: &Object, _: Sel, _item: id) { + let menu_id = unsafe { + let ptr: usize = *this.get_ivar(BLOCK_PTR); + let obj = ptr as *const Action; + &*obj + }; + let event = Event::MenuEvent(menu_id.0.to_string()); + AppState::queue_event(EventWrapper::StaticEvent(event)); +} + +extern "C" fn dealloc_custom_menuitem(this: &Object, _: Sel) { + unsafe { + let ptr: usize = *this.get_ivar(BLOCK_PTR); + let obj = ptr as *mut Action; + if !obj.is_null() { + let _handler = Box::from_raw(obj); + } + let _: () = msg_send![super(this, class!(NSMenuItem)), dealloc]; + } +} diff --git a/src/platform_impl/macos/util/async.rs b/src/platform_impl/macos/util/async.rs index 7f8e7b8714..2005c79bb2 100644 --- a/src/platform_impl/macos/util/async.rs +++ b/src/platform_impl/macos/util/async.rs @@ -14,6 +14,7 @@ use objc::runtime::NO; use crate::{ dpi::LogicalSize, + menu::Menu, platform_impl::platform::{ffi, util::IdRef, window::SharedState}, }; @@ -205,6 +206,11 @@ pub unsafe fn set_title_async(ns_window: id, title: String) { }); } +// `setMenu:` isn't thread-safe. +pub unsafe fn set_menu_async(_ns_window: id, menu: Option>) { + dbg!(menu); +} + // `close:` is thread-safe, but we want the event to be triggered from the main // thread. Though, it's a good idea to look into that more... // diff --git a/src/platform_impl/macos/window.rs b/src/platform_impl/macos/window.rs index 7d16f2fce3..9f0c4ed74b 100644 --- a/src/platform_impl/macos/window.rs +++ b/src/platform_impl/macos/window.rs @@ -15,12 +15,13 @@ use crate::{ }, error::{ExternalError, NotSupportedError, OsError as RootOsError}, icon::Icon, + menu::Menu, monitor::{MonitorHandle as RootMonitorHandle, VideoMode as RootVideoMode}, platform::macos::{ActivationPolicy, WindowExtMacOS}, platform_impl::platform::{ app_state::AppState, app_state::INTERRUPT_EVENT_LOOP_EXIT, - ffi, + ffi, menu, monitor::{self, MonitorHandle, VideoMode}, util::{self, IdRef}, view::CursorState, @@ -260,6 +261,11 @@ fn create_window( if attrs.position.is_none() { ns_window.center(); } + + if let Some(window_menu) = attrs.window_menu.clone() { + menu::initialize(window_menu); + } + ns_window }); pool.drain(); @@ -476,6 +482,12 @@ impl UnownedWindow { } } + pub fn set_menu(&self, menu: Option>) { + unsafe { + util::set_menu_async(*self.ns_window, menu); + } + } + pub fn set_visible(&self, visible: bool) { match visible { true => unsafe { util::make_key_and_order_front_async(*self.ns_window) }, diff --git a/src/window.rs b/src/window.rs index 079c711240..fe1ea83701 100644 --- a/src/window.rs +++ b/src/window.rs @@ -5,6 +5,7 @@ use crate::{ dpi::{PhysicalPosition, PhysicalSize, Position, Size}, error::{ExternalError, NotSupportedError, OsError}, event_loop::EventLoopWindowTarget, + menu::Menu, monitor::{MonitorHandle, VideoMode}, platform_impl, }; @@ -186,6 +187,11 @@ pub struct WindowAttributes { /// /// The default is `None`. pub window_icon: Option, + + /// The window menu. + /// + /// The default is `None`. + pub window_menu: Option>, } impl Default for WindowAttributes { @@ -205,6 +211,7 @@ impl Default for WindowAttributes { decorations: true, always_on_top: false, window_icon: None, + window_menu: None, } } } @@ -355,6 +362,17 @@ impl WindowBuilder { self } + /// Requests a specific title for the window. + /// + /// See [`Window::set_menu`] for details. + /// + /// [`Window::set_menu`]: crate::window::Window::set_menu + #[inline] + pub fn with_menu>>(mut self, menu: M) -> Self { + self.window.window_menu = Some(menu.into()); + self + } + /// Builds the window. /// /// Possible causes of error include denied permission, incompatible system, and lack of memory. @@ -747,6 +765,17 @@ impl Window { pub fn request_user_attention(&self, request_type: Option) { self.window.request_user_attention(request_type) } + + /// Modifies the menu of the window. + /// + /// ## Platform-specific + /// + /// - Only supported on macOS. + + #[inline] + pub fn set_menu(&self, menu: Option>) { + self.window.set_menu(menu) + } } /// Cursor functions.