From 9e73f74c403661d54b210e83785e33b1e5d7980c Mon Sep 17 00:00:00 2001 From: Joe Neeman Date: Sun, 14 Feb 2021 19:52:14 -0600 Subject: [PATCH 1/4] Redo the menu public API. Menus are now updated semi-automatically from the app data. --- druid-shell/src/application.rs | 11 +- druid-shell/src/hotkey.rs | 4 +- druid-shell/src/platform/gtk/window.rs | 3 +- druid-shell/src/platform/mac/application.rs | 7 + druid/examples/markdown_preview.rs | 29 +- druid/examples/multiwin.rs | 146 ++-- druid/examples/textbox.rs | 29 +- druid/src/app.rs | 27 +- druid/src/app_delegate.rs | 18 +- druid/src/command.rs | 8 +- druid/src/contexts.rs | 26 +- druid/src/lib.rs | 4 +- druid/src/menu.rs | 811 ------------------- druid/src/menu/mod.rs | 844 ++++++++++++++++++++ druid/src/menu/sys.rs | 326 ++++++++ druid/src/win_handler.rs | 94 +-- druid/src/window.rs | 57 +- 17 files changed, 1391 insertions(+), 1053 deletions(-) delete mode 100644 druid/src/menu.rs create mode 100644 druid/src/menu/mod.rs create mode 100644 druid/src/menu/sys.rs diff --git a/druid-shell/src/application.rs b/druid-shell/src/application.rs index 14ee66728f..f59f0ce5e4 100644 --- a/druid-shell/src/application.rs +++ b/druid-shell/src/application.rs @@ -169,7 +169,7 @@ impl Application { self.platform_app.quit() } - // TODO: do these two go in some kind of PlatformExt trait? + // TODO: do these three go in some kind of PlatformExt trait? /// Hide the application this window belongs to. (cmd+H) pub fn hide(&self) { #[cfg(target_os = "macos")] @@ -182,6 +182,15 @@ impl Application { self.platform_app.hide_others() } + /// Sets the global application menu, on platforms where there is one. + /// + /// On platforms with no global application menu, this has no effect. + #[allow(unused_variables)] + pub fn set_menu(&self, menu: crate::Menu) { + #[cfg(target_os = "macos")] + self.platform_app.set_menu(menu.into_inner()); + } + /// Returns a handle to the system clipboard. pub fn clipboard(&self) -> Clipboard { self.platform_app.clipboard().into() diff --git a/druid-shell/src/hotkey.rs b/druid-shell/src/hotkey.rs index c4dedb3a58..25b69c3761 100644 --- a/druid-shell/src/hotkey.rs +++ b/druid-shell/src/hotkey.rs @@ -55,7 +55,7 @@ use crate::{IntoKey, KbKey, KeyEvent, Modifiers}; /// ``` /// /// [`SysMods`]: enum.SysMods.html -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct HotKey { pub(crate) mods: RawMods, pub(crate) key: KbKey, @@ -140,7 +140,7 @@ pub enum SysMods { /// A representation of the active modifier keys. /// /// This is intended to be clearer than `Modifiers`, when describing hotkeys. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum RawMods { None, Alt, diff --git a/druid-shell/src/platform/gtk/window.rs b/druid-shell/src/platform/gtk/window.rs index 9ace08f24c..422a3455a1 100644 --- a/druid-shell/src/platform/gtk/window.rs +++ b/druid-shell/src/platform/gtk/window.rs @@ -1121,7 +1121,8 @@ impl WindowHandle { .unwrap(); let first_child = &vbox.get_children()[0]; - if first_child.is::() { + if let Some(old_menubar) = first_child.downcast_ref::() { + old_menubar.deactivate(); vbox.remove(first_child); } let menubar = menu.into_gtk_menubar(&self, &accel_group); diff --git a/druid-shell/src/platform/mac/application.rs b/druid-shell/src/platform/mac/application.rs index 87cc8cf886..8417fc9b7a 100644 --- a/druid-shell/src/platform/mac/application.rs +++ b/druid-shell/src/platform/mac/application.rs @@ -32,6 +32,7 @@ use crate::application::AppHandler; use super::clipboard::Clipboard; use super::error::Error; +use super::menu::Menu; use super::util; static APP_HANDLER_IVAR: &str = "druidAppHandler"; @@ -117,6 +118,12 @@ impl Application { } } + pub fn set_menu(&self, menu: Menu) { + unsafe { + NSApp().setMainMenu_(menu.menu); + } + } + pub fn clipboard(&self) -> Clipboard { Clipboard } diff --git a/druid/examples/markdown_preview.rs b/druid/examples/markdown_preview.rs index b5d8bd689e..69c073f03e 100644 --- a/druid/examples/markdown_preview.rs +++ b/druid/examples/markdown_preview.rs @@ -21,7 +21,8 @@ use druid::widget::prelude::*; use druid::widget::{Controller, LineBreaking, RawLabel, Scroll, Split, TextBox}; use druid::{ AppDelegate, AppLauncher, Color, Command, Data, DelegateCtx, FontFamily, FontStyle, FontWeight, - Handled, Lens, LocalizedString, MenuDesc, Selector, Target, Widget, WidgetExt, WindowDesc, + Handled, Lens, LocalizedString, Menu, Selector, Target, Widget, WidgetExt, WindowDesc, + WindowId, }; const WINDOW_TITLE: LocalizedString = LocalizedString::new("Minimal Markdown"); @@ -93,7 +94,7 @@ pub fn main() { // describe the main window let main_window = WindowDesc::new(build_root_widget()) .title(WINDOW_TITLE) - .menu(make_menu()) + .menu(make_menu) .window_size((700.0, 600.0)); // create the initial app state @@ -228,23 +229,23 @@ fn add_attribute_for_tag(tag: &Tag, mut attrs: AttributesAdder) { } #[allow(unused_assignments, unused_mut)] -fn make_menu() -> MenuDesc { - let mut base = MenuDesc::empty(); +fn make_menu(_window_id: Option, _app_state: &AppState, _env: &Env) -> Menu { + let mut base = Menu::empty(); #[cfg(target_os = "macos")] { - base = base.append(druid::platform_menus::mac::application::default()) + base = base.entry(druid::platform_menus::mac::application::default()) } #[cfg(any(target_os = "windows", target_os = "linux"))] { - base = base.append(druid::platform_menus::win::file::default()); + base = base.entry(druid::platform_menus::win::file::default()); } - base.append( - MenuDesc::new(LocalizedString::new("common-menu-edit-menu")) - .append(druid::platform_menus::common::undo()) - .append(druid::platform_menus::common::redo()) - .append_separator() - .append(druid::platform_menus::common::cut().disabled()) - .append(druid::platform_menus::common::copy()) - .append(druid::platform_menus::common::paste()), + base.entry( + Menu::new(LocalizedString::new("common-menu-edit-menu")) + .entry(druid::platform_menus::common::undo()) + .entry(druid::platform_menus::common::redo()) + .separator() + .entry(druid::platform_menus::common::cut().enabled(false)) + .entry(druid::platform_menus::common::copy()) + .entry(druid::platform_menus::common::paste()), ) } diff --git a/druid/examples/multiwin.rs b/druid/examples/multiwin.rs index ec0471b513..0e564229ac 100644 --- a/druid/examples/multiwin.rs +++ b/druid/examples/multiwin.rs @@ -21,16 +21,10 @@ use druid::widget::{ use druid::Target::Global; use druid::{ commands as sys_cmds, AppDelegate, AppLauncher, Application, Color, Command, ContextMenu, Data, - DelegateCtx, Handled, LocalizedString, MenuDesc, MenuItem, Selector, Target, WindowDesc, - WindowId, + DelegateCtx, Handled, LocalizedString, Menu, MenuItem, Target, WindowDesc, WindowId, }; use tracing::info; -const MENU_COUNT_ACTION: Selector = Selector::new("menu-count-action"); -const MENU_INCREMENT_ACTION: Selector = Selector::new("menu-increment-action"); -const MENU_DECREMENT_ACTION: Selector = Selector::new("menu-decrement-action"); -const MENU_SWITCH_GLOW_ACTION: Selector = Selector::new("menu-switch-glow"); - #[derive(Debug, Clone, Default, Data)] struct State { menu_count: usize, @@ -39,11 +33,9 @@ struct State { } pub fn main() { - let main_window = WindowDesc::new(ui_builder()) - .menu(make_menu(&State::default())) - .title( - LocalizedString::new("multiwin-demo-window-title").with_placeholder("Many windows!"), - ); + let main_window = WindowDesc::new(ui_builder()).menu(make_menu).title( + LocalizedString::new("multiwin-demo-window-title").with_placeholder("Many windows!"), + ); AppLauncher::with_window(main_window) .delegate(Delegate { windows: Vec::new(), @@ -57,10 +49,10 @@ fn ui_builder() -> impl Widget { let text = LocalizedString::new("hello-counter") .with_arg("count", |data: &State, _env| data.menu_count.into()); let label = Label::new(text); - let inc_button = Button::::new("Add menu item") - .on_click(|ctx, _data, _env| ctx.submit_command(MENU_INCREMENT_ACTION.to(Global))); + let inc_button = + Button::::new("Add menu item").on_click(|_ctx, data, _env| data.menu_count += 1); let dec_button = Button::::new("Remove menu item") - .on_click(|ctx, _data, _env| ctx.submit_command(MENU_DECREMENT_ACTION.to(Global))); + .on_click(|_ctx, data, _env| data.menu_count = data.menu_count.saturating_sub(1)); let new_button = Button::::new("New window").on_click(|ctx, _data, _env| { ctx.submit_command(sys_cmds::NEW_FILE.to(Global)); }); @@ -134,11 +126,18 @@ struct Delegate { windows: Vec, } -impl> Controller for ContextMenuController { - fn event(&mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { +impl> Controller for ContextMenuController { + fn event( + &mut self, + child: &mut W, + ctx: &mut EventCtx, + event: &Event, + data: &mut State, + env: &Env, + ) { match event { Event::MouseDown(ref mouse) if mouse.button.is_right() => { - let menu = ContextMenu::new(make_context_menu::(), mouse.pos); + let menu = ContextMenu::new(make_context_menu, mouse.pos); ctx.show_context_menu(menu); } _ => child.event(ctx, event, data, env), @@ -155,45 +154,14 @@ impl AppDelegate for Delegate { data: &mut State, _env: &Env, ) -> Handled { - match cmd { - _ if cmd.is(sys_cmds::NEW_FILE) => { - let new_win = WindowDesc::new(ui_builder()) - .menu(make_menu(data)) - .window_size((data.selected as f64 * 100.0 + 300.0, 500.0)); - ctx.new_window(new_win); - Handled::Yes - } - _ if cmd.is(MENU_COUNT_ACTION) => { - data.selected = *cmd.get_unchecked(MENU_COUNT_ACTION); - let menu = make_menu::(data); - for id in &self.windows { - ctx.set_menu(menu.clone(), *id); - } - Handled::Yes - } - // wouldn't it be nice if a menu (like a button) could just mutate state - // directly if desired? - _ if cmd.is(MENU_INCREMENT_ACTION) => { - data.menu_count += 1; - let menu = make_menu::(data); - for id in &self.windows { - ctx.set_menu(menu.clone(), *id); - } - Handled::Yes - } - _ if cmd.is(MENU_DECREMENT_ACTION) => { - data.menu_count = data.menu_count.saturating_sub(1); - let menu = make_menu::(data); - for id in &self.windows { - ctx.set_menu(menu.clone(), *id); - } - Handled::Yes - } - _ if cmd.is(MENU_SWITCH_GLOW_ACTION) => { - data.glow_hot = !data.glow_hot; - Handled::Yes - } - _ => Handled::No, + if cmd.is(sys_cmds::NEW_FILE) { + let new_win = WindowDesc::new(ui_builder()) + .menu(make_menu) + .window_size((data.selected as f64 * 100.0 + 300.0, 500.0)); + ctx.new_window(new_win); + Handled::Yes + } else { + Handled::No } } @@ -223,46 +191,48 @@ impl AppDelegate for Delegate { } #[allow(unused_assignments)] -fn make_menu(state: &State) -> MenuDesc { - let mut base = MenuDesc::empty(); +fn make_menu(_: Option, state: &State, _: &Env) -> Menu { + let mut base = Menu::empty(); #[cfg(target_os = "macos")] { base = druid::platform_menus::mac::menu_bar(); } #[cfg(any(target_os = "windows", target_os = "linux"))] { - base = base.append(druid::platform_menus::win::file::default()); + base = base.entry(druid::platform_menus::win::file::default()); } if state.menu_count != 0 { - base = base.append( - MenuDesc::new(LocalizedString::new("Custom")).append_iter(|| { - (1..state.menu_count + 1).map(|i| { - MenuItem::new( - LocalizedString::new("hello-counter") - .with_arg("count", move |_, _| i.into()), - MENU_COUNT_ACTION.with(i), - ) - .disabled_if(|| i % 3 == 0) - .selected_if(|| i == state.selected) - }) - }), - ); + let mut custom = Menu::new(LocalizedString::new("Custom")); + + for i in 1..=state.menu_count { + custom = custom.entry( + MenuItem::new( + LocalizedString::new("hello-counter") + .with_arg("count", move |_: &State, _| i.into()), + ) + .on_activate(move |_ctx, data, _env| data.selected = i) + .enabled_if(move |_data, _env| i % 3 != 0) + .selected_if(move |data, _env| i == data.selected), + ); + } + base = base.entry(custom); } - base + base.rebuild_on(|old_data, data, _env| old_data.menu_count != data.menu_count) } -fn make_context_menu() -> MenuDesc { - MenuDesc::empty() - .append(MenuItem::new( - LocalizedString::new("Increment"), - MENU_INCREMENT_ACTION, - )) - .append(MenuItem::new( - LocalizedString::new("Decrement"), - MENU_DECREMENT_ACTION, - )) - .append(MenuItem::new( - LocalizedString::new("Glow when hot"), - MENU_SWITCH_GLOW_ACTION, - )) +fn make_context_menu(_state: &State, _env: &Env) -> Menu { + Menu::empty() + .entry( + MenuItem::new(LocalizedString::new("Increment")) + .on_activate(|_ctx, data: &mut State, _env| data.menu_count += 1), + ) + .entry( + MenuItem::new(LocalizedString::new("Decrement")).on_activate( + |_ctx, data: &mut State, _env| data.menu_count = data.menu_count.saturating_sub(1), + ), + ) + .entry( + MenuItem::new(LocalizedString::new("Glow when hot")) + .on_activate(|_ctx, data: &mut State, _env| data.glow_hot = !data.glow_hot), + ) } diff --git a/druid/examples/textbox.rs b/druid/examples/textbox.rs index 7cf2570943..346328a2bf 100644 --- a/druid/examples/textbox.rs +++ b/druid/examples/textbox.rs @@ -21,7 +21,8 @@ use std::sync::Arc; use druid::widget::{Flex, Label, TextBox}; use druid::{ - AppLauncher, Color, Data, Lens, LocalizedString, MenuDesc, Widget, WidgetExt, WindowDesc, + AppLauncher, Color, Data, Env, Lens, LocalizedString, Menu, Widget, WidgetExt, WindowDesc, + WindowId, }; const WINDOW_TITLE: LocalizedString = LocalizedString::new("Text Options"); @@ -44,7 +45,7 @@ pub fn main() { // describe the main window let main_window = WindowDesc::new(build_root_widget()) .title(WINDOW_TITLE) - .menu(make_menu()) + .menu(make_menu) .window_size((400.0, 600.0)); // create the initial app state @@ -88,23 +89,23 @@ fn build_root_widget() -> impl Widget { } #[allow(unused_assignments, unused_mut)] -fn make_menu() -> MenuDesc { - let mut base = MenuDesc::empty(); +fn make_menu(_window: Option, _data: &AppState, _env: &Env) -> Menu { + let mut base = Menu::empty(); #[cfg(target_os = "macos")] { - base = base.append(druid::platform_menus::mac::application::default()) + base = base.entry(druid::platform_menus::mac::application::default()) } #[cfg(any(target_os = "windows", target_os = "linux"))] { - base = base.append(druid::platform_menus::win::file::default()); + base = base.entry(druid::platform_menus::win::file::default()); } - base.append( - MenuDesc::new(LocalizedString::new("common-menu-edit-menu")) - .append(druid::platform_menus::common::undo()) - .append(druid::platform_menus::common::redo()) - .append_separator() - .append(druid::platform_menus::common::cut()) - .append(druid::platform_menus::common::copy()) - .append(druid::platform_menus::common::paste()), + base.entry( + Menu::new(LocalizedString::new("common-menu-edit-menu")) + .entry(druid::platform_menus::common::undo()) + .entry(druid::platform_menus::common::redo()) + .separator() + .entry(druid::platform_menus::common::cut()) + .entry(druid::platform_menus::common::copy()) + .entry(druid::platform_menus::common::paste()), ) } diff --git a/druid/src/app.rs b/druid/src/app.rs index a79dea274b..9f62f743d8 100644 --- a/druid/src/app.rs +++ b/druid/src/app.rs @@ -16,11 +16,12 @@ use crate::ext_event::{ExtEventHost, ExtEventSink}; use crate::kurbo::{Point, Size}; +use crate::menu::MenuManager; use crate::shell::{Application, Error as PlatformError, WindowBuilder, WindowHandle, WindowLevel}; use crate::widget::LabelText; use crate::win_handler::{AppHandler, AppState}; use crate::window::WindowId; -use crate::{AppDelegate, Data, Env, LocalizedString, MenuDesc, Widget}; +use crate::{AppDelegate, Data, Env, LocalizedString, Menu, Widget}; use druid_shell::WindowState; @@ -81,7 +82,7 @@ pub struct PendingWindow { pub(crate) root: Box>, pub(crate) title: LabelText, pub(crate) transparent: bool, - pub(crate) menu: Option>, + pub(crate) menu: Option>, pub(crate) size_policy: WindowSizePolicy, // This is copied over from the WindowConfig // when the native window is constructed. } @@ -96,7 +97,7 @@ impl PendingWindow { PendingWindow { root: Box::new(root), title: LocalizedString::new("app-name").into(), - menu: MenuDesc::platform_default(), + menu: MenuManager::platform_default(), transparent: false, size_policy: WindowSizePolicy::User, } @@ -120,8 +121,15 @@ impl PendingWindow { } /// Set the menu for this window. - pub fn menu(mut self, menu: MenuDesc) -> Self { - self.menu = Some(menu); + /// + /// `menu` is a callback for creating the menu. Its first argument is the id of the window that + /// will have the menu, or `None` if it's creating the root application menu for an app with no + /// menus (which can happen, for example, on macOS). + pub fn menu( + mut self, + menu: impl FnMut(Option, &T, &Env) -> Menu + 'static, + ) -> Self { + self.menu = Some(MenuManager::new(menu)); self } } @@ -465,7 +473,14 @@ impl WindowDesc { } /// Set the menu for this window. - pub fn menu(mut self, menu: MenuDesc) -> Self { + /// + /// `menu` is a callback for creating the menu. Its first argument is the id of the window that + /// will have the menu, or `None` if it's creating the root application menu for an app with no + /// menus (which can happen, for example, on macOS). + pub fn menu( + mut self, + menu: impl FnMut(Option, &T, &Env) -> Menu + 'static, + ) -> Self { self.pending = self.pending.menu(menu); self } diff --git a/druid/src/app_delegate.rs b/druid/src/app_delegate.rs index 3c0e578468..ab444d1677 100644 --- a/druid/src/app_delegate.rs +++ b/druid/src/app_delegate.rs @@ -18,7 +18,7 @@ use std::any::{Any, TypeId}; use crate::{ commands, core::CommandQueue, ext_event::ExtEventHost, Command, Data, Env, Event, ExtEventSink, - Handled, MenuDesc, SingleUse, Target, WindowDesc, WindowId, + Handled, SingleUse, Target, WindowDesc, WindowId, }; /// A context passed in to [`AppDelegate`] functions. @@ -69,22 +69,6 @@ impl<'a> DelegateCtx<'a> { debug_panic!("DelegateCtx::new_window - T must match the application data type."); } } - - /// Set the window's menu. - /// `T` must be the application's root `Data` type (the type provided to [`AppLauncher::launch`]). - /// - /// [`AppLauncher::launch`]: struct.AppLauncher.html#method.launch - pub fn set_menu(&mut self, menu: MenuDesc, window: WindowId) { - if self.app_data_type == TypeId::of::() { - self.submit_command( - commands::SET_MENU - .with(Box::new(menu)) - .to(Target::Window(window)), - ); - } else { - debug_panic!("DelegateCtx::set_menu - T must match the application data type."); - } - } } /// A type that provides hooks for handling and modifying top-level events. diff --git a/druid/src/command.rs b/druid/src/command.rs index d66f58825c..0bd3f74b90 100644 --- a/druid/src/command.rs +++ b/druid/src/command.rs @@ -224,7 +224,7 @@ pub mod sys { /// object to be displayed. /// /// [`ContextMenu`]: ../struct.ContextMenu.html - pub(crate) const SHOW_CONTEXT_MENU: Selector> = + pub(crate) const SHOW_CONTEXT_MENU: Selector>> = Selector::new("druid-builtin.show-context-menu"); /// This is sent to the window handler to create a new sub window. @@ -240,12 +240,6 @@ pub mod sys { pub(crate) const SUB_WINDOW_HOST_TO_PARENT: Selector> = Selector::new("druid-builtin.host_to_parent"); - /// The selector for a command to set the window's menu. The payload should - /// be a [`MenuDesc`] object. - /// - /// [`MenuDesc`]: ../struct.MenuDesc.html - pub(crate) const SET_MENU: Selector> = Selector::new("druid-builtin.set-menu"); - /// Show the application preferences. pub const SHOW_PREFERENCES: Selector = Selector::new("druid-builtin.menu-show-preferences"); diff --git a/druid/src/contexts.rs b/druid/src/contexts.rs index ffaee23938..10f4d1d213 100644 --- a/druid/src/contexts.rs +++ b/druid/src/contexts.rs @@ -31,7 +31,7 @@ use crate::shell::Region; use crate::text::{ImeHandlerRef, TextFieldRegistration}; use crate::{ commands, sub_window::SubWindowDesc, widget::Widget, Affine, Command, ContextMenu, Cursor, - Data, Env, ExtEventSink, Insets, MenuDesc, Notification, Point, Rect, SingleUse, Size, Target, + Data, Env, ExtEventSink, Insets, Notification, Point, Rect, SingleUse, Size, Target, TimerToken, Vec2, WidgetId, WindowConfig, WindowDesc, WindowHandle, WindowId, }; @@ -376,15 +376,6 @@ impl_context_method!(EventCtx<'_, '_>, UpdateCtx<'_, '_>, LifeCycleCtx<'_, '_>, self.request_layout(); } - /// Set the menu of the window containing the current widget. - /// `T` must be the application's root `Data` type (the type provided to [`AppLauncher::launch`]). - /// - /// [`AppLauncher::launch`]: struct.AppLauncher.html#method.launch - pub fn set_menu(&mut self, menu: MenuDesc) { - trace!("set_menu"); - self.state.set_menu(menu); - } - /// Indicate that text input state has changed. /// /// A widget that accepts text input should call this anytime input state @@ -535,7 +526,7 @@ impl EventCtx<'_, '_> { if self.state.root_app_data_type == TypeId::of::() { self.submit_command( commands::SHOW_CONTEXT_MENU - .with(Box::new(menu)) + .with(SingleUse::new(Box::new(menu))) .to(Target::Window(self.state.window_id)), ); } else { @@ -881,19 +872,6 @@ impl<'a> ContextState<'a> { .push_back(command.default_to(self.window_id.into())); } - fn set_menu(&mut self, menu: MenuDesc) { - trace!("set_menu"); - if self.root_app_data_type == TypeId::of::() { - self.submit_command( - commands::SET_MENU - .with(Box::new(menu)) - .to(Target::Window(self.window_id)), - ); - } else { - debug_panic!("EventCtx::set_menu - T must match the application data type."); - } - } - fn request_timer(&self, widget_state: &mut WidgetState, deadline: Duration) -> TimerToken { trace!("request_timer deadline={:?}", deadline); let timer_token = self.window.request_timer(deadline); diff --git a/druid/src/lib.rs b/druid/src/lib.rs index 8ab21b2285..c836a05584 100644 --- a/druid/src/lib.rs +++ b/druid/src/lib.rs @@ -161,7 +161,7 @@ mod env; mod event; mod ext_event; mod localization; -mod menu; +pub mod menu; mod mouse; pub mod scroll_component; mod sub_window; @@ -204,7 +204,7 @@ pub use event::{Event, InternalEvent, InternalLifeCycle, LifeCycle}; pub use ext_event::{ExtEventError, ExtEventSink}; pub use lens::{Lens, LensExt}; pub use localization::LocalizedString; -pub use menu::{sys as platform_menus, ContextMenu, MenuDesc, MenuItem}; +pub use menu::{sys as platform_menus, ContextMenu, Menu, MenuItem}; pub use mouse::MouseEvent; pub use text::{ArcStr, FontDescriptor, TextLayout}; pub use util::Handled; diff --git a/druid/src/menu.rs b/druid/src/menu.rs deleted file mode 100644 index be9398f7fb..0000000000 --- a/druid/src/menu.rs +++ /dev/null @@ -1,811 +0,0 @@ -// Copyright 2019 The Druid Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Menus. -//! -//! # How menus work -//! -//! The types here are a generalized 'menu description'; concrete menus -//! are part of `druid-shell`. -//! -//! We deal principally with the [`MenuDesc`] type. When you create a window, -//! you can give it a `MenuDesc`, which will be turned into a concrete menu -//! object on the current platform when the window is built. -//! -//! ## Commands -//! -//! To handle an event from a menu, you assign that menu a [`Command`], and -//! handle the [`Command` event] somewhere in your widget tree. Certain -//! special events are handled by the system; these special commands are available -//! as consts in [`Selector`]. -//! -//! ## Changing the menu -//! -//! To change the menu for a window, you issue a [`SET_MENU`] command, the payload -//! of which should be a new [`MenuDesc`]. The new menu will replace the old menu. -//! -//! ## The macOS app menu -//! -//! On macOS, the main menu belongs to the application, not to the window. -//! -//! In druid, whichever window is frontmost will have its menu displayed as -//! the application menu. -//! -//! # Examples -//! -//! Creating the default Application menu for macOS: -//! -//! ``` -//! use druid::{Data, LocalizedString, MenuDesc, MenuItem, SysMods}; -//! use druid::commands; -//! -//! fn macos_application_menu() -> MenuDesc { -//! MenuDesc::new(LocalizedString::new("macos-menu-application-menu")) -//! .append(MenuItem::new( -//! LocalizedString::new("macos-menu-about-app"), -//! commands::SHOW_ABOUT, -//! )) -//! .append_separator() -//! .append( -//! MenuItem::new( -//! LocalizedString::new("macos-menu-preferences"), -//! commands::SHOW_PREFERENCES, -//! ) -//! .hotkey(SysMods::Cmd, ",") -//! .disabled(), -//! ) -//! .append_separator() -//! .append(MenuDesc::new(LocalizedString::new("macos-menu-services"))) -//! .append( -//! MenuItem::new( -//! LocalizedString::new("macos-menu-hide-app"), -//! commands::HIDE_APPLICATION, -//! ) -//! .hotkey(SysMods::Cmd, "h"), -//! ) -//! .append( -//! MenuItem::new( -//! LocalizedString::new("macos-menu-hide-others"), -//! commands::HIDE_OTHERS, -//! ) -//! .hotkey(SysMods::AltCmd, "h"), -//! ) -//! .append( -//! MenuItem::new( -//! LocalizedString::new("macos-menu-show-all"), -//! commands::SHOW_ALL, -//! ) -//! .disabled(), -//! ) -//! .append_separator() -//! .append( -//! MenuItem::new( -//! LocalizedString::new("macos-menu-quit-app"), -//! commands::QUIT_APP, -//! ) -//! .hotkey(SysMods::Cmd, "q"), -//! ) -//! } -//! ``` -//! -//! [`MenuDesc`]: struct.MenuDesc.html -//! [`Command`]: ../struct.Command.html -//! [`Command` event]: ../enum.Event.html#variant.Command -//! [`Selector`]: ../struct.Selector.html -//! [`SET_MENU`]: ../struct.Selector.html#associatedconstant.SET_MENU - -use std::num::NonZeroU32; - -use crate::kurbo::Point; -use crate::shell::{HotKey, IntoKey, Menu as PlatformMenu, RawMods, SysMods}; -use crate::{commands, Command, Data, Env, LocalizedString, Selector}; - -/// A platform-agnostic description of an application, window, or context -/// menu. -#[derive(Clone)] -pub struct MenuDesc { - item: MenuItem, - //TODO: make me an RC if we're cloning regularly? - items: Vec>, -} - -/// An item in a menu, which may be a normal item, a submenu, or a separator. -#[derive(Debug, Clone)] -#[allow(clippy::large_enum_variant)] -pub enum MenuEntry { - Item(MenuItem), - SubMenu(MenuDesc), - Separator, -} - -/// A normal menu item. -/// -/// A `MenuItem` always has a title (a [`LocalizedString`]) as well a [`Command`], -/// that is sent to the application when the item is selected. -/// -/// In addition, other properties can be set during construction, such as whether -/// the item is selected (checked), or enabled, or if it has a hotkey. -/// -/// [`LocalizedString`]: struct.LocalizedString.html -/// [`Command`]: struct.Command.html -#[derive(Debug, Clone)] -pub struct MenuItem { - title: LocalizedString, - command: Command, - hotkey: Option, - tool_tip: Option>, - //highlighted: bool, - selected: bool, - enabled: bool, // (or state is stored elsewhere) - /// Identifies the platform object corresponding to this item. - platform_id: MenuItemId, -} - -/// A menu displayed as a pop-over. -#[derive(Debug, Clone)] -pub struct ContextMenu { - pub(crate) menu: MenuDesc, - pub(crate) location: Point, -} - -/// Uniquely identifies a menu item. -/// -/// On the druid-shell side, the id is represented as a u32. -/// We reserve '0' as a placeholder value; on the Rust side -/// we represent this as an `Option`, which better -/// represents the semantics of our program. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct MenuItemId(Option); - -impl MenuItem { - /// Create a new `MenuItem`. - pub fn new(title: LocalizedString, command: impl Into) -> Self { - MenuItem { - title, - command: command.into(), - hotkey: None, - tool_tip: None, - selected: false, - enabled: true, - platform_id: MenuItemId::PLACEHOLDER, - } - } - - /// A builder method that adds a hotkey for this item. - /// - /// # Example - /// - /// ``` - /// # use druid::{LocalizedString, MenuDesc, MenuItem, Selector, SysMods}; - /// - /// let item = MenuItem::new(LocalizedString::new("My Menu Item"), Selector::new("My Selector")) - /// .hotkey(SysMods::Cmd, "m"); - /// - /// # // hide the type param in or example code by letting it be inferred here - /// # MenuDesc::::empty().append(item); - /// ``` - pub fn hotkey(mut self, mods: impl Into>, key: impl IntoKey) -> Self { - self.hotkey = Some(HotKey::new(mods, key)); - self - } - - /// Disable this menu item. - pub fn disabled(mut self) -> Self { - self.enabled = false; - self - } - - /// Disable this menu item if the provided predicate is true. - pub fn disabled_if(mut self, mut p: impl FnMut() -> bool) -> Self { - if p() { - self.enabled = false; - } - self - } - - /// Mark this menu item as selected. This will usually be indicated by - /// a checkmark. - pub fn selected(mut self) -> Self { - self.selected = true; - self - } - - /// Mark this item as selected, if the provided predicate is true. - pub fn selected_if(mut self, mut p: impl FnMut() -> bool) -> Self { - if p() { - self.selected = true; - } - self - } -} - -impl MenuDesc { - /// Create a new, empty menu. - pub fn empty() -> Self { - Self::new(LocalizedString::new("")) - } - - /// Create a new menu with the given title. - pub fn new(title: LocalizedString) -> Self { - let item = MenuItem::new(title, Selector::NOOP); - MenuDesc { - item, - items: Vec::new(), - } - } - - /// If this platform always expects windows to have a menu by default, - /// returns a menu. Otherwise returns `None`. - #[allow(unreachable_code)] - pub fn platform_default() -> Option> { - #[cfg(target_os = "macos")] - return Some(MenuDesc::empty().append(sys::mac::application::default())); - #[cfg(target_os = "windows")] - return None; - - // we want to explicitly handle all platforms; log if a platform is missing. - tracing::warn!("MenuDesc::platform_default is not implemented for this platform."); - None - } - - /// Given a function that produces an iterator, appends that iterator's - /// items to this menu. - /// - /// # Examples - /// - /// ``` - /// use druid::{Command, LocalizedString, MenuDesc, MenuItem, Selector, Target}; - /// - /// let num_items: usize = 4; - /// const MENU_COUNT_ACTION: Selector = Selector::new("menu-count-action"); - /// - /// let my_menu: MenuDesc = MenuDesc::empty() - /// .append_iter(|| (0..num_items).map(|i| { - /// MenuItem::new( - /// LocalizedString::new("hello-counter").with_arg("count", move |_, _| i.into()), - /// Command::new(MENU_COUNT_ACTION, i, Target::Auto), - /// ) - /// }) - /// ); - /// - /// assert_eq!(my_menu.len(), 4); - /// ``` - pub fn append_iter>>(mut self, f: impl FnOnce() -> I) -> Self { - for item in f() { - self.items.push(item.into()); - } - self - } - - /// Append an item to this menu. - pub fn append(mut self, item: impl Into>) -> Self { - self.items.push(item.into()); - self - } - - /// Append an item to this menu if the predicate is matched. - pub fn append_if(mut self, item: impl Into>, mut p: impl FnMut() -> bool) -> Self { - if p() { - self.items.push(item.into()); - } - self - } - - /// Append a separator. - pub fn append_separator(mut self) -> Self { - self.items.push(MenuEntry::Separator); - self - } - - /// The number of items in the menu. - pub fn len(&self) -> usize { - self.items.len() - } - - /// Returns `true` if the menu contains no items. - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } - - /// Build an application or window menu for the current platform. - /// - /// This takes self as &mut because it resolves localization. - pub(crate) fn build_window_menu(&mut self, data: &T, env: &Env) -> PlatformMenu { - self.build_native_menu(data, env, false) - } - - /// Build a popup menu for the current platform. - /// - /// This takes self as &mut because it resolves localization. - pub(crate) fn build_popup_menu(&mut self, data: &T, env: &Env) -> PlatformMenu { - self.build_native_menu(data, env, true) - } - - /// impl shared for window & context menus - fn build_native_menu(&mut self, data: &T, env: &Env, for_popup: bool) -> PlatformMenu { - let mut menu = if for_popup { - PlatformMenu::new_for_popup() - } else { - PlatformMenu::new() - }; - for item in &mut self.items { - match item { - MenuEntry::Item(ref mut item) => { - item.title.resolve(data, env); - item.platform_id = MenuItemId::next(); - menu.add_item( - item.platform_id.as_u32(), - &item.title.localized_str(), - item.hotkey.as_ref(), - item.enabled, - item.selected, - ); - } - MenuEntry::Separator => menu.add_separator(), - MenuEntry::SubMenu(ref mut submenu) => { - let sub = submenu.build_native_menu(data, env, false); - submenu.item.title.resolve(data, env); - menu.add_dropdown( - sub, - &submenu.item.title.localized_str(), - submenu.item.enabled, - ); - } - } - } - menu - } - - /// Given a command identifier from druid-shell, returns the command - /// corresponding to that id in this menu, if one exists. - pub(crate) fn command_for_id(&self, id: u32) -> Option { - for item in &self.items { - match item { - MenuEntry::Item(item) if item.platform_id.as_u32() == id => { - return Some(item.command.clone()) - } - MenuEntry::SubMenu(menu) => { - if let Some(cmd) = menu.command_for_id(id) { - return Some(cmd); - } - } - _ => (), - } - } - None - } -} - -impl ContextMenu { - /// Create a new `ContextMenu`. - pub fn new(menu: MenuDesc, location: Point) -> Self { - ContextMenu { menu, location } - } -} - -impl MenuItemId { - /// The value for a menu item that has not been instantiated by - /// the platform. - const PLACEHOLDER: MenuItemId = MenuItemId(None); - - fn next() -> Self { - use std::sync::atomic::{AtomicU32, Ordering}; - static MENU_ID: AtomicU32 = AtomicU32::new(1); - let raw = NonZeroU32::new(MENU_ID.fetch_add(2, Ordering::Relaxed)); - MenuItemId(raw) - } - - fn as_u32(self) -> u32 { - match self.0 { - Some(val) => val.get(), - None => 0, - } - } -} - -impl std::fmt::Debug for MenuDesc { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - fn menu_debug_impl( - menu: &MenuDesc, - f: &mut std::fmt::Formatter, - level: usize, - ) -> std::fmt::Result { - static TABS: &str = - " "; - let indent = &TABS[..level * 2]; - let child_indent = &TABS[..(level + 1) * 2]; - writeln!(f, "{}{}", indent, menu.item.title.key)?; - for item in &menu.items { - match item { - MenuEntry::Item(item) => writeln!(f, "{}{}", child_indent, item.title.key)?, - MenuEntry::Separator => writeln!(f, "{} --------- ", child_indent)?, - MenuEntry::SubMenu(ref menu) => menu_debug_impl(menu, f, level + 1)?, - } - } - Ok(()) - } - - menu_debug_impl(self, f, 0) - } -} - -impl From> for MenuEntry { - fn from(src: MenuItem) -> MenuEntry { - MenuEntry::Item(src) - } -} - -impl From> for MenuEntry { - fn from(src: MenuDesc) -> MenuEntry { - MenuEntry::SubMenu(src) - } -} - -/// Pre-configured, platform appropriate menus and menu items. -pub mod sys { - use super::*; - - /// Menu items that exist on all platforms. - pub mod common { - use super::*; - /// 'Cut'. - pub fn cut() -> MenuItem { - MenuItem::new(LocalizedString::new("common-menu-cut"), commands::CUT) - .hotkey(SysMods::Cmd, "x") - } - - /// The 'Copy' menu item. - pub fn copy() -> MenuItem { - MenuItem::new(LocalizedString::new("common-menu-copy"), commands::COPY) - .hotkey(SysMods::Cmd, "c") - } - - /// The 'Paste' menu item. - pub fn paste() -> MenuItem { - MenuItem::new(LocalizedString::new("common-menu-paste"), commands::PASTE) - .hotkey(SysMods::Cmd, "v") - } - - /// The 'Undo' menu item. - pub fn undo() -> MenuItem { - MenuItem::new(LocalizedString::new("common-menu-undo"), commands::UNDO) - .hotkey(SysMods::Cmd, "z") - } - - /// The 'Redo' menu item. - pub fn redo() -> MenuItem { - let item = MenuItem::new(LocalizedString::new("common-menu-redo"), commands::REDO); - - #[cfg(target_os = "windows")] - { - item.hotkey(SysMods::Cmd, "y") - } - #[cfg(not(target_os = "windows"))] - { - item.hotkey(SysMods::CmdShift, "Z") - } - } - } - - /// Windows. - pub mod win { - use super::*; - - /// The 'File' menu. - /// - /// These items are taken from [the win32 documentation][]. - /// - /// [the win32 documentation]: https://docs.microsoft.com/en-us/windows/win32/uxguide/cmd-menus#standard-menus - pub mod file { - use super::*; - use crate::FileDialogOptions; - - /// A default file menu. - /// - /// This will not be suitable for many applications; you should - /// build the menu you need manually, using the items defined here - /// where appropriate. - pub fn default() -> MenuDesc { - MenuDesc::new(LocalizedString::new("common-menu-file-menu")) - .append(new()) - .append(open()) - .append(close()) - .append(save_ellipsis()) - .append(save_as()) - // revert to saved? - .append(print().disabled()) - .append(page_setup().disabled()) - .append_separator() - .append(exit()) - } - - /// The 'New' menu item. - pub fn new() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-new"), - commands::NEW_FILE, - ) - .hotkey(SysMods::Cmd, "n") - } - - /// The 'Open...' menu item. - pub fn open() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-open"), - commands::SHOW_OPEN_PANEL.with(FileDialogOptions::default()), - ) - .hotkey(SysMods::Cmd, "o") - } - - /// The 'Close' menu item. - pub fn close() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-close"), - commands::CLOSE_WINDOW, - ) - } - - /// The 'Save' menu item. - pub fn save() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-save"), - commands::SAVE_FILE, - ) - .hotkey(SysMods::Cmd, "s") - } - - /// The 'Save...' menu item. - /// - /// This is used if we need to show a dialog to select save location. - pub fn save_ellipsis() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-save-ellipsis"), - commands::SHOW_SAVE_PANEL.with(FileDialogOptions::default()), - ) - .hotkey(SysMods::Cmd, "s") - } - - /// The 'Save as...' menu item. - pub fn save_as() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-save-as"), - commands::SHOW_SAVE_PANEL.with(FileDialogOptions::default()), - ) - .hotkey(SysMods::CmdShift, "S") - } - - /// The 'Print...' menu item. - pub fn print() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-print"), - commands::PRINT, - ) - .hotkey(SysMods::Cmd, "p") - } - - /// The 'Print Preview' menu item. - pub fn print_preview() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-print-preview"), - commands::PRINT_PREVIEW, - ) - } - - /// The 'Page Setup...' menu item. - pub fn page_setup() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-page-setup"), - commands::PRINT_SETUP, - ) - } - - /// The 'Exit' menu item. - pub fn exit() -> MenuItem { - MenuItem::new( - LocalizedString::new("win-menu-file-exit"), - commands::QUIT_APP, - ) - } - } - } - - /// macOS. - pub mod mac { - use super::*; - - /// A basic macOS menu bar. - pub fn menu_bar() -> MenuDesc { - MenuDesc::new(LocalizedString::new("")) - .append(application::default()) - .append(file::default()) - } - - /// The application menu - pub mod application { - use super::*; - - /// The default Application menu. - pub fn default() -> MenuDesc { - MenuDesc::new(LocalizedString::new("macos-menu-application-menu")) - .append(about()) - .append_separator() - .append(preferences().disabled()) - .append_separator() - //.append(MenuDesc::new(LocalizedString::new("macos-menu-services"))) - .append(hide()) - .append(hide_others()) - .append(show_all().disabled()) - .append_separator() - .append(quit()) - } - - /// The 'About App' menu item. - pub fn about() -> MenuItem { - MenuItem::new( - LocalizedString::new("macos-menu-about-app"), - commands::SHOW_ABOUT, - ) - } - - /// The preferences menu item. - pub fn preferences() -> MenuItem { - MenuItem::new( - LocalizedString::new("macos-menu-preferences"), - commands::SHOW_PREFERENCES, - ) - .hotkey(SysMods::Cmd, ",") - } - - /// The 'Hide' builtin menu item. - pub fn hide() -> MenuItem { - MenuItem::new( - LocalizedString::new("macos-menu-hide-app"), - commands::HIDE_APPLICATION, - ) - .hotkey(SysMods::Cmd, "h") - } - - /// The 'Hide Others' builtin menu item. - pub fn hide_others() -> MenuItem { - MenuItem::new( - LocalizedString::new("macos-menu-hide-others"), - commands::HIDE_OTHERS, - ) - .hotkey(SysMods::AltCmd, "h") - } - - /// The 'show all' builtin menu item - //FIXME: this doesn't work - pub fn show_all() -> MenuItem { - MenuItem::new( - LocalizedString::new("macos-menu-show-all"), - commands::SHOW_ALL, - ) - } - - /// The 'Quit' menu item. - pub fn quit() -> MenuItem { - MenuItem::new( - LocalizedString::new("macos-menu-quit-app"), - commands::QUIT_APP, - ) - .hotkey(SysMods::Cmd, "q") - } - } - /// The file menu. - pub mod file { - use super::*; - use crate::FileDialogOptions; - - /// A default file menu. - /// - /// This will not be suitable for many applications; you should - /// build the menu you need manually, using the items defined here - /// where appropriate. - pub fn default() -> MenuDesc { - MenuDesc::new(LocalizedString::new("common-menu-file-menu")) - .append(new_file()) - .append(open_file()) - // open recent? - .append_separator() - .append(close()) - .append(save().disabled()) - .append(save_as().disabled()) - // revert to saved? - .append_separator() - .append(page_setup().disabled()) - .append(print().disabled()) - } - - /// The 'New Window' item. - /// - /// Note: depending on context, apps might show 'New', 'New Window', - /// 'New File', or 'New...' (where the last indicates that the menu - /// item will open a prompt). You may want to create a custom - /// item to capture the intent of your menu, instead of using this one. - pub fn new_file() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-new"), - commands::NEW_FILE, - ) - .hotkey(SysMods::Cmd, "n") - } - - /// The 'Open...' menu item. Will display the system file-chooser. - pub fn open_file() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-open"), - commands::SHOW_OPEN_PANEL.with(FileDialogOptions::default()), - ) - .hotkey(SysMods::Cmd, "o") - } - - /// The 'Close' menu item. - pub fn close() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-close"), - commands::CLOSE_WINDOW, - ) - .hotkey(SysMods::Cmd, "w") - } - - /// The 'Save' menu item. - pub fn save() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-save"), - commands::SAVE_FILE, - ) - .hotkey(SysMods::Cmd, "s") - } - - /// The 'Save...' menu item. - /// - /// This is used if we need to show a dialog to select save location. - pub fn save_ellipsis() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-save-ellipsis"), - commands::SHOW_SAVE_PANEL.with(FileDialogOptions::default()), - ) - .hotkey(SysMods::Cmd, "s") - } - - /// The 'Save as...' - pub fn save_as() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-save-as"), - commands::SHOW_SAVE_PANEL.with(FileDialogOptions::default()), - ) - .hotkey(SysMods::CmdShift, "S") - } - - /// The 'Page Setup...' menu item. - pub fn page_setup() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-page-setup"), - commands::PRINT_SETUP, - ) - .hotkey(SysMods::CmdShift, "P") - } - - /// The 'Print...' menu item. - pub fn print() -> MenuItem { - MenuItem::new( - LocalizedString::new("common-menu-file-print"), - commands::PRINT, - ) - .hotkey(SysMods::Cmd, "p") - } - } - } -} diff --git a/druid/src/menu/mod.rs b/druid/src/menu/mod.rs new file mode 100644 index 0000000000..24ad6f9cf9 --- /dev/null +++ b/druid/src/menu/mod.rs @@ -0,0 +1,844 @@ +// Copyright 2019 The Druid Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! ## Window, application, and context menus +//! +//! Menus in Druid follow a data-driven design similar to that of the main widget tree. The main +//! types are [`Menu`] (representing a tree of menus and submenus) and [`MenuItem`] (representing a +//! single "leaf" element). +//! +//! ## Menu actions +//! +//! Menu items can be associated with callbacks, which are triggered when a user selects that menu +//! item. Each callback has access to the application data, and also gets access to a +//! [`MenuEventCtx`], which allows for submitting [`Command`]s. +//! +//! ## Refreshing and rebuilding +//! +//! Menus, like widgets, update themselves based on changes in the data. There are two different +//! ways that the menus update themselves: +//! +//! - a "refresh" is when the menu items update their text or their status (e.g. disabled, +//! selected) based on changes to the data. Menu refreshes are handled for you automatically. For +//! example, if you create a menu item whose title is a [`LabelText::Dynamic`] then that title +//! will be kept up-to-date for you. +//! +//! The limitation of a "refresh" is that it cannot change the structure of the menus (e.g. by +//! adding new items or moving things around). +//! +//! - a "rebuild" is when the menu is rebuilt from scratch. When you first set a menu (e.g. using +//! [`WindowDesc::menu`]), you provide a callback for building the menu from data; a rebuild is +//! when the menu decides to rebuild itself by invoking that callback again. +//! +//! Rebuilds have none of the limitations of refreshes, but Druid does not automatically decide +//! when to do them. You need to use [`Menu::rebuild_on`] to decide when rebuild should +//! occur. +//! +//! ## The macOS app menu +//! +//! On macOS, the main menu belongs to the application, not to the window. +//! +//! In Druid, whichever window is frontmost will have its menu displayed as the application menu. +//! +//! ## Examples +//! +//! Creating the default app menu for macOS: +//! +//! ``` +//! use druid::commands; +//! use druid::{Data, LocalizedString, Menu, MenuItem, SysMods}; +//! +//! fn macos_application_menu() -> Menu { +//! Menu::new(LocalizedString::new("macos-menu-application-menu")) +//! .entry( +//! MenuItem::new(LocalizedString::new("macos-menu-about-app")) +//! // You need to handle the SHOW_ABOUT command yourself (or else do something +//! // directly to the data here instead of using a command). +//! .command(commands::SHOW_ABOUT), +//! ) +//! .separator() +//! .entry( +//! MenuItem::new(LocalizedString::new("macos-menu-preferences")) +//! // You need to handle the SHOW_PREFERENCES command yourself (or else do something +//! // directly to the data here instead of using a command). +//! .command(commands::SHOW_PREFERENCES) +//! .hotkey(SysMods::Cmd, ","), +//! ) +//! .separator() +//! .entry(MenuItem::new(LocalizedString::new("macos-menu-services"))) +//! .entry( +//! MenuItem::new(LocalizedString::new("macos-menu-hide-app")) +//! // druid handles the HIDE_APPLICATION command automatically +//! .command(commands::HIDE_APPLICATION) +//! .hotkey(SysMods::Cmd, "h"), +//! ) +//! .entry( +//! MenuItem::new(LocalizedString::new("macos-menu-hide-others")) +//! // druid handles the HIDE_OTHERS command automatically +//! .command(commands::HIDE_OTHERS) +//! .hotkey(SysMods::AltCmd, "h"), +//! ) +//! .entry( +//! MenuItem::new(LocalizedString::new("macos-menu-show-all")) +//! // You need to handle the SHOW_ALL command yourself (or else do something +//! // directly to the data here instead of using a command). +//! .command(commands::SHOW_ALL) +//! ) +//! .separator() +//! .entry( +//! MenuItem::new(LocalizedString::new("macos-menu-quit-app")) +//! // druid handles the QUIT_APP command automatically +//! .command(commands::QUIT_APP) +//! .hotkey(SysMods::Cmd, "q"), +//! ) +//! } +//! ``` +//! +//! [`LabelText::Dynamic`]: crate::widget::LabelText::Dynamic +//! [`WindowDesc::menu`]: crate::WindowDesc::menu +//! [`Command`]: crate::Command + +use std::num::NonZeroU32; + +use crate::core::CommandQueue; +use crate::kurbo::Point; +use crate::shell::{Counter, HotKey, IntoKey, Menu as PlatformMenu}; +use crate::widget::LabelText; +use crate::{ArcStr, Command, Data, Env, Lens, RawMods, Target, WindowId}; + +static COUNTER: Counter = Counter::new(); + +pub mod sys; + +type MenuBuild = Box, &T, &Env) -> Menu>; + +/// This is for completely recreating the menus (for when you want to change the actual menu +/// structure, rather than just, say, enabling or disabling entries). +pub(crate) struct MenuManager { + build: MenuBuild, + popup: bool, + old_data: Option, + menu: Option>, +} + +/// A menu displayed as a pop-over. +pub struct ContextMenu { + pub(crate) build: Box Menu>, + pub(crate) location: Point, +} + +impl ContextMenu { + /// Create a new [`ContextMenu`]. + pub fn new( + build: impl FnMut(&T, &Env) -> Menu + 'static, + location: Point, + ) -> ContextMenu { + ContextMenu { + build: Box::new(build), + location, + } + } +} + +impl MenuManager { + /// Create a new [`MenuManager`] for a title-bar menu. + pub fn new( + build: impl FnMut(Option, &T, &Env) -> Menu + 'static, + ) -> MenuManager { + MenuManager { + build: Box::new(build), + popup: false, + old_data: None, + menu: None, + } + } + + /// Create a new [`MenuManager`] for a context menu. + pub fn new_for_popup(mut build: impl FnMut(&T, &Env) -> Menu + 'static) -> MenuManager { + MenuManager { + build: Box::new(move |_, data, env| build(data, env)), + popup: true, + old_data: None, + menu: None, + } + } + + /// If this platform always expects windows to have a menu by default, returns a menu. + /// Otherwise, returns `None`. + #[allow(unreachable_code)] + pub fn platform_default() -> Option> { + #[cfg(target_os = "macos")] + return Some(MenuManager::new(|_, _, _| sys::mac::application::default())); + + #[cfg(any(target_os = "windows", target_os = "linux"))] + return None; + + // we want to explicitly handle all platforms; log if a platform is missing. + tracing::warn!("MenuManager::platform_default is not implemented for this platform."); + None + } + + /// Called when a menu event is received from the system. + pub fn event( + &mut self, + queue: &mut CommandQueue, + window: Option, + id: MenuItemId, + data: &mut T, + env: &Env, + ) { + if let Some(m) = &mut self.menu { + let mut ctx = MenuEventCtx { queue, window }; + m.activate(&mut ctx, id, data, env); + } + } + + /// Build an initial menu from the application data. + pub fn initialize(&mut self, window: Option, data: &T, env: &Env) -> PlatformMenu { + self.menu = Some((self.build)(window, data, env)); + self.old_data = Some(data.clone()); + self.refresh(data, env) + } + + /// Update the menu based on a change to the data. + /// + /// Returns a new `PlatformMenu` if the menu has changed; returns `None` if it hasn't. + pub fn update( + &mut self, + window: Option, + data: &T, + env: &Env, + ) -> Option { + if let (Some(menu), Some(old_data)) = (self.menu.as_mut(), self.old_data.as_ref()) { + let ret = match menu.update(old_data, data, env) { + MenuUpdate::NeedsRebuild => { + self.menu = Some((self.build)(window, data, env)); + Some(self.refresh(data, env)) + } + MenuUpdate::NeedsRefresh => Some(self.refresh(data, env)), + MenuUpdate::UpToDate => None, + }; + self.old_data = Some(data.clone()); + ret + } else { + tracing::error!("tried to update uninitialized menus"); + None + } + } + + /// Builds a new menu for displaying the given data. + /// + /// Mostly you should probably use `update` instead, because that actually checks whether a + /// refresh is necessary. + pub fn refresh(&mut self, data: &T, env: &Env) -> PlatformMenu { + if let Some(menu) = self.menu.as_mut() { + let mut ctx = MenuBuildCtx::new(self.popup); + menu.refresh_children(&mut ctx, data, env); + ctx.current + } else { + tracing::error!("tried to refresh uninitialized menus"); + PlatformMenu::new() + } + } +} + +/// This context is available to the callback that is called when a menu item is activated. +/// +/// Currently, it only allows for submission of [`Command`]s. +/// +/// [`Command`]: crate::Command +pub struct MenuEventCtx<'a> { + window: Option, + queue: &'a mut CommandQueue, +} + +/// This context helps menu items to build the platform menu. +struct MenuBuildCtx { + current: PlatformMenu, +} + +impl MenuBuildCtx { + fn new(popup: bool) -> MenuBuildCtx { + MenuBuildCtx { + current: if popup { + PlatformMenu::new_for_popup() + } else { + PlatformMenu::new() + }, + } + } + + fn with_submenu(&mut self, text: &str, enabled: bool, f: impl FnOnce(&mut MenuBuildCtx)) { + let mut child = MenuBuildCtx::new(false); + f(&mut child); + self.current.add_dropdown(child.current, text, enabled); + } + + fn add_item( + &mut self, + id: u32, + text: &str, + key: Option<&HotKey>, + enabled: bool, + selected: bool, + ) { + self.current.add_item(id, text, key, enabled, selected); + } + + fn add_separator(&mut self) { + self.current.add_separator(); + } +} + +impl<'a> MenuEventCtx<'a> { + /// Submit a [`Command`] to be handled by the main widget tree. + /// + /// If the command's target is [`Target::Auto`], it will be sent to the menu's window if the + /// menu is associated with a window, or to [`Target::Global`] if the menu is not associated + /// with a window. + /// + /// See [`EventCtx::submit_command`] for more information. + /// + /// [`Command`]: crate::Command + /// [`EventCtx::submit_command`]: crate::EventCtx::submit_command + /// [`Target::Auto`]: crate::Target::Auto + /// [`Target::Global`]: crate::Target::Global + pub fn submit_command(&mut self, cmd: impl Into) { + self.queue.push_back( + cmd.into() + .default_to(self.window.map(Target::Window).unwrap_or(Target::Global)), + ); + } +} + +#[derive(Clone, Copy, Debug)] +enum MenuUpdate { + /// The structure of the current menu is ok, but some elements need to be refreshed (e.g. + /// changing their text, whether they are enabled, etc.) + NeedsRefresh, + /// The structure of the menu has changed; we need to rebuilt from scratch. + NeedsRebuild, + /// No need to rebuild anything. + UpToDate, +} + +impl MenuUpdate { + fn combine(self, other: MenuUpdate) -> MenuUpdate { + use MenuUpdate::*; + match (self, other) { + (NeedsRebuild, _) | (_, NeedsRebuild) => NeedsRebuild, + (NeedsRefresh, _) | (_, NeedsRefresh) => NeedsRefresh, + _ => UpToDate, + } + } +} + +/// This is the trait that enables recursive visiting of all menu entries. It isn't publically +/// visible (the publically visible analogue of this is `Into>`). +trait MenuVisitor { + /// Called when a menu item is activated. + /// + /// `id` is the id of the entry that got activated. If this is different from your id, you are + /// responsible for routing the activation to your child items. + fn activate(&mut self, ctx: &mut MenuEventCtx, id: MenuItemId, data: &mut T, env: &Env); + + /// Called when the data is changed. + fn update(&mut self, old_data: &T, data: &T, env: &Env) -> MenuUpdate; + + /// Called to refresh the menu. + fn refresh(&mut self, ctx: &mut MenuBuildCtx, data: &T, env: &Env); +} + +/// A wrapper for a menu item (or submenu) to give it access to a part of its parent data. +/// +/// This is the menu analogue of [`LensWrap`]. You will usually create it with [`Menu::lens`] or +/// [`MenuItem::lens`] instead of using this struct directly. +/// +/// [`LensWrap`]: crate::widget::LensWrap +pub struct MenuLensWrap { + lens: L, + inner: Box>, + old_data: Option, + old_env: Option, +} + +impl> MenuVisitor for MenuLensWrap { + fn activate(&mut self, ctx: &mut MenuEventCtx, id: MenuItemId, data: &mut T, env: &Env) { + let inner = &mut self.inner; + self.lens + .with_mut(data, |u| inner.activate(ctx, id, u, env)); + } + + fn update(&mut self, old_data: &T, data: &T, env: &Env) -> MenuUpdate { + let inner = &mut self.inner; + let lens = &self.lens; + let cached_old_data = &mut self.old_data; + let cached_old_env = &mut self.old_env; + lens.with(old_data, |old| { + lens.with(data, |new| { + let ret = if cached_old_data.as_ref().map(|x| x.same(old)) == Some(true) + && cached_old_env.as_ref().map(|x| x.same(env)) == Some(true) + { + MenuUpdate::UpToDate + } else { + inner.update(old, new, env) + }; + *cached_old_data = Some(new.clone()); + *cached_old_env = Some(env.clone()); + ret + }) + }) + } + + fn refresh(&mut self, ctx: &mut MenuBuildCtx, data: &T, env: &Env) { + let inner = &mut self.inner; + self.lens.with(data, |u| inner.refresh(ctx, u, env)) + } +} + +impl + 'static> From> for MenuEntry { + fn from(m: MenuLensWrap) -> MenuEntry { + MenuEntry { inner: Box::new(m) } + } +} + +/// An entry in a menu. +/// +/// An entry is either a [`MenuItem`], a submenu (i.e. [`Menu`]), or one of a few other +/// possibilities (such as one of the two options above, wrapped in a [`MenuLensWrap`]). +pub struct MenuEntry { + inner: Box>, +} + +type MenuPredicate = Box bool>; + +/// A menu. +/// +/// Menus can be nested arbitrarily, so this could also be a submenu. +/// See the [module level documentation](crate::menu) for more on how to use menus. +pub struct Menu { + rebuild_on: Option>, + refresh_on: Option>, + item: MenuItem, + children: Vec>, + // bloom? +} + +#[doc(hidden)] +#[deprecated(since = "0.8.0", note = "Renamed to Menu")] +pub type MenuDesc = Menu; + +impl From> for MenuEntry { + fn from(menu: Menu) -> MenuEntry { + MenuEntry { + inner: Box::new(menu), + } + } +} + +type MenuCallback = Box; +type HotKeyCallback = Box Option>; + +/// An item in a menu. +/// +/// See the [module level documentation](crate::menu) for more on how to use menus. +pub struct MenuItem { + id: MenuItemId, + + title: LabelText, + callback: Option>, + hotkey: Option>, + selected: Option bool>>, + enabled: Option bool>>, + + // The last resolved state of this menu item. This is basically consists of all the properties + // above, but "static" versions of them not depending on the data. + old_state: Option, +} + +impl From> for MenuEntry { + fn from(i: MenuItem) -> MenuEntry { + MenuEntry { inner: Box::new(i) } + } +} + +struct Separator; + +impl From for MenuEntry { + fn from(s: Separator) -> MenuEntry { + MenuEntry { inner: Box::new(s) } + } +} + +impl Menu { + /// Create an empty menu. + pub fn empty() -> Menu { + Menu { + rebuild_on: None, + refresh_on: None, + item: MenuItem::new(""), + children: Vec::new(), + } + } + + /// Create a menu with the given name. + pub fn new(title: impl Into>) -> Menu { + Menu { + rebuild_on: None, + refresh_on: None, + item: MenuItem::new(title), + children: Vec::new(), + } + } + + /// Provide a callback for determining whether this item should be enabled. + /// + /// Whenever the callback returns `true`, the menu will be enabled. + pub fn enabled_if(mut self, enabled: impl FnMut(&T, &Env) -> bool + 'static) -> Self { + self.item = self.item.enabled_if(enabled); + self + } + + /// Enable or disable this menu. + pub fn enabled(self, enabled: bool) -> Self { + self.enabled_if(move |_data, _env| enabled) + } + + #[doc(hidden)] + #[deprecated(since = "0.8.0", note = "use entry instead")] + pub fn append_entry(self, entry: impl Into>) -> Self { + self.entry(entry) + } + + #[doc(hidden)] + #[deprecated(since = "0.8.0", note = "use entry instead")] + pub fn append_separator(self) -> Self { + self.separator() + } + + /// Append a menu entry to this menu, returning the modified menu. + pub fn entry(mut self, entry: impl Into>) -> Self { + self.children.push(entry.into()); + self + } + + /// Append a separator to this menu, returning the modified menu. + pub fn separator(self) -> Self { + self.entry(Separator) + } + + /// Supply a function to check when this menu needs to refresh itself. + /// + /// The arguments to the callback are (in order): + /// - the previous value of the data, + /// - the current value of the data, and + /// - the current value of the environment. + /// + /// The callback should return true if the menu needs to refresh itself. + /// + /// This callback is intended to be purely an optimization. If you do create a menu without + /// supplying a refresh callback, the menu will recursively check whether any children have + /// changed and refresh itself if any have. By supplying a callback here, you can short-circuit + /// those recursive calls. + pub fn refresh_on(mut self, refresh: impl FnMut(&T, &T, &Env) -> bool + 'static) -> Self { + self.refresh_on = Some(Box::new(refresh)); + self + } + + /// Supply a function to check when this menu needs to be rebuild from scratch. + /// + /// The arguments to the callback are (in order): + /// - the previous value of the data, + /// - the current value of the data, and + /// - the current value of the environment. + /// + /// The callback should return true if the menu needs to be rebuilt. + /// + /// The difference between rebuilding and refreshing (as in [`refresh_on`]) is + /// that rebuilding creates the menu from scratch using the original menu-building callback, + /// whereas refreshing involves tweaking the existing menu entries (e.g. enabling or disabling + /// items). + /// + /// If you do not provide a callback using this method, the menu will never get rebuilt. + /// + /// [`refresh_on`]: self::Menu::refresh_on + pub fn rebuild_on(mut self, rebuild: impl FnMut(&T, &T, &Env) -> bool + 'static) -> Self { + self.rebuild_on = Some(Box::new(rebuild)); + self + } + + /// Wraps this menu in a lens, so that it can be added to a `Menu`. + pub fn lens(self, lens: impl Lens + 'static) -> MenuEntry { + MenuLensWrap { + lens, + inner: Box::new(self), + old_data: None, + old_env: None, + } + .into() + } + + // This is like MenuVisitor::refresh, but it doesn't add a submenu for the current level. + // (This is the behavior we need for the top-level (unnamed) menu, which contains (e.g.) File, + // Edit, etc. as submenus.) + fn refresh_children(&mut self, ctx: &mut MenuBuildCtx, data: &T, env: &Env) { + self.item.resolve(data, env); + for child in &mut self.children { + child.refresh(ctx, data, env); + } + } +} + +impl MenuItem { + /// Create a new menu item with a given name. + pub fn new(title: impl Into>) -> MenuItem { + let mut id = COUNTER.next() as u32; + if id == 0 { + id = COUNTER.next() as u32; + } + MenuItem { + id: MenuItemId(std::num::NonZeroU32::new(id)), + title: title.into(), + callback: None, + hotkey: None, + selected: None, + enabled: None, + old_state: None, + } + } + + /// Provide a callback that will be invoked when this menu item is chosen. + pub fn on_activate( + mut self, + on_activate: impl FnMut(&mut MenuEventCtx, &mut T, &Env) + 'static, + ) -> Self { + self.callback = Some(Box::new(on_activate)); + self + } + + /// Provide a [`Command`] that will be sent when this menu item is chosen. + /// + /// This is equivalent to `self.on_activate(move |ctx, _data, _env| ctx.submit_command(cmd))`. + /// If the command's target is [`Target::Auto`], it will be sent to the menu's window if the + /// menu is associated with a window, or to [`Target::Global`] if the menu is not associated + /// with a window. + /// + /// [`Command`]: crate::Command + /// [`Target::Auto`]: crate::Target::Auto + /// [`Target::Global`]: crate::Target::Global + pub fn command(self, cmd: impl Into) -> Self { + let cmd = cmd.into(); + self.on_activate(move |ctx, _data, _env| ctx.submit_command(cmd.clone())) + } + + /// Provide a hotkey for activating this menu item. + /// + /// This is equivalent to + /// `self.dynamic_hotkey(move |_, _| Some(HotKey::new(mods, key))` + pub fn hotkey(self, mods: impl Into>, key: impl IntoKey) -> Self { + let hotkey = HotKey::new(mods, key); + self.dynamic_hotkey(move |_, _| Some(hotkey.clone())) + } + + /// Provide a dynamic hotkey for activating this menu item. + /// + /// The hotkey can change depending on the data. + pub fn dynamic_hotkey( + mut self, + hotkey: impl FnMut(&T, &Env) -> Option + 'static, + ) -> Self { + self.hotkey = Some(Box::new(hotkey)); + self + } + + /// Provide a callback for determining whether this menu item should be enabled. + /// + /// Whenever the callback returns `true`, the item will be enabled. + pub fn enabled_if(mut self, enabled: impl FnMut(&T, &Env) -> bool + 'static) -> Self { + self.enabled = Some(Box::new(enabled)); + self + } + + /// Enable or disable this menu item. + pub fn enabled(self, enabled: bool) -> Self { + self.enabled_if(move |_data, _env| enabled) + } + + /// Provide a callback for determining whether this menu item should be selected. + /// + /// Whenever the callback returns `true`, the item will be selected. + pub fn selected_if(mut self, selected: impl FnMut(&T, &Env) -> bool + 'static) -> Self { + self.selected = Some(Box::new(selected)); + self + } + + /// Select or deselect this menu item. + pub fn selected(self, selected: bool) -> Self { + self.selected_if(move |_data, _env| selected) + } + + /// Wraps this menu item in a lens, so that it can be added to a `Menu`. + pub fn lens(self, lens: impl Lens + 'static) -> MenuEntry { + MenuLensWrap { + lens, + inner: Box::new(self), + old_data: None, + old_env: None, + } + .into() + } + + fn resolve(&mut self, data: &T, env: &Env) -> bool { + self.title.resolve(data, env); + let new_state = MenuItemState { + title: self.title.display_text(), + hotkey: self.hotkey.as_mut().and_then(|h| h(data, env)), + selected: self + .selected + .as_mut() + .map(|s| s(data, env)) + .unwrap_or(false), + enabled: self.enabled.as_mut().map(|e| e(data, env)).unwrap_or(true), + }; + let ret = self.old_state.as_ref() != Some(&new_state); + self.old_state = Some(new_state); + ret + } + + // Panics if we haven't been resolved. + fn text(&self) -> &str { + &self.old_state.as_ref().unwrap().title + } + + // Panics if we haven't been resolved. + fn is_enabled(&self) -> bool { + self.old_state.as_ref().unwrap().enabled + } +} + +impl MenuVisitor for Menu { + fn activate(&mut self, ctx: &mut MenuEventCtx, id: MenuItemId, data: &mut T, env: &Env) { + for child in &mut self.children { + child.activate(ctx, id, data, env); + } + } + + fn update(&mut self, old_data: &T, data: &T, env: &Env) -> MenuUpdate { + if let Some(rebuild_on) = &mut self.rebuild_on { + if rebuild_on(old_data, data, env) { + return MenuUpdate::NeedsRebuild; + } + } + if let Some(refresh_on) = &mut self.refresh_on { + if refresh_on(old_data, data, env) { + return MenuUpdate::NeedsRefresh; + } + } + + let mut ret = self.item.update(old_data, data, env); + for child in &mut self.children { + ret = ret.combine(child.update(old_data, data, env)); + } + ret + } + + fn refresh(&mut self, ctx: &mut MenuBuildCtx, data: &T, env: &Env) { + self.item.resolve(data, env); + let children = &mut self.children; + ctx.with_submenu(self.item.text(), self.item.is_enabled(), |ctx| { + for child in children { + child.refresh(ctx, data, env); + } + }); + } +} + +impl MenuVisitor for MenuEntry { + fn activate(&mut self, ctx: &mut MenuEventCtx, id: MenuItemId, data: &mut T, env: &Env) { + self.inner.activate(ctx, id, data, env); + } + + fn update(&mut self, old_data: &T, data: &T, env: &Env) -> MenuUpdate { + self.inner.update(old_data, data, env) + } + + fn refresh(&mut self, ctx: &mut MenuBuildCtx, data: &T, env: &Env) { + self.inner.refresh(ctx, data, env); + } +} + +impl MenuVisitor for MenuItem { + fn activate(&mut self, ctx: &mut MenuEventCtx, id: MenuItemId, data: &mut T, env: &Env) { + if id == self.id { + if let Some(callback) = &mut self.callback { + callback(ctx, data, env); + } + } + } + + fn update(&mut self, _old_data: &T, data: &T, env: &Env) -> MenuUpdate { + if self.resolve(data, env) { + MenuUpdate::NeedsRefresh + } else { + MenuUpdate::UpToDate + } + } + + fn refresh(&mut self, ctx: &mut MenuBuildCtx, data: &T, env: &Env) { + self.resolve(data, env); + let state = self.old_state.as_ref().unwrap(); + ctx.add_item( + self.id.0.map(|x| x.get()).unwrap_or(0), + &state.title, + state.hotkey.as_ref(), + state.enabled, + state.selected, + ); + } +} + +impl MenuVisitor for Separator { + fn activate(&mut self, _ctx: &mut MenuEventCtx, _id: MenuItemId, _data: &mut T, _env: &Env) {} + + fn update(&mut self, _old_data: &T, _data: &T, _env: &Env) -> MenuUpdate { + MenuUpdate::UpToDate + } + fn refresh(&mut self, ctx: &mut MenuBuildCtx, _data: &T, _env: &Env) { + ctx.add_separator(); + } +} + +// The resolved state of a menu item. +#[derive(PartialEq)] +struct MenuItemState { + title: ArcStr, + hotkey: Option, + selected: bool, + enabled: bool, +} + +/// Uniquely identifies a menu item. +/// +/// On the druid-shell side, the id is represented as a u32. +/// We reserve '0' as a placeholder value; on the Rust side +/// we represent this as an `Option`, which better +/// represents the semantics of our program. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct MenuItemId(Option); + +impl MenuItemId { + pub(crate) fn new(id: u32) -> MenuItemId { + MenuItemId(NonZeroU32::new(id)) + } +} diff --git a/druid/src/menu/sys.rs b/druid/src/menu/sys.rs new file mode 100644 index 0000000000..a3af20b239 --- /dev/null +++ b/druid/src/menu/sys.rs @@ -0,0 +1,326 @@ +// Copyright 2019 The Druid Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Pre-configured, platform appropriate menus and menu items. + +use crate::{commands, LocalizedString, SysMods}; + +use super::*; + +/// Menu items that exist on all platforms. +pub mod common { + use super::*; + /// 'Cut'. + pub fn cut() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-cut")) + .command(commands::CUT) + .hotkey(SysMods::Cmd, "x") + } + + /// The 'Copy' menu item. + pub fn copy() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-copy")) + .command(commands::COPY) + .hotkey(SysMods::Cmd, "c") + } + + /// The 'Paste' menu item. + pub fn paste() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-paste")) + .command(commands::PASTE) + .hotkey(SysMods::Cmd, "v") + } + + /// The 'Undo' menu item. + pub fn undo() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-undo")) + .command(commands::UNDO) + .hotkey(SysMods::Cmd, "z") + } + + /// The 'Redo' menu item. + pub fn redo() -> MenuItem { + let item = MenuItem::new(LocalizedString::new("common-menu-redo")).command(commands::REDO); + + #[cfg(target_os = "windows")] + { + item.hotkey(SysMods::Cmd, "y") + } + #[cfg(not(target_os = "windows"))] + { + item.hotkey(SysMods::CmdShift, "Z") + } + } +} + +/// Windows. +pub mod win { + use super::*; + + /// The 'File' menu. + /// + /// These items are taken from [the win32 documentation][]. + /// + /// [the win32 documentation]: https://docs.microsoft.com/en-us/windows/win32/uxguide/cmd-menus#standard-menus + pub mod file { + use super::*; + use crate::FileDialogOptions; + + /// A default file menu. + /// + /// This will not be suitable for many applications; you should + /// build the menu you need manually, using the items defined here + /// where appropriate. + pub fn default() -> Menu { + Menu::new(LocalizedString::new("common-menu-file-menu")) + .entry(new()) + .entry(open()) + .entry(close()) + .entry(save_ellipsis()) + .entry(save_as()) + // revert to saved? + .entry(print()) + .entry(page_setup()) + .separator() + .entry(exit()) + } + + /// The 'New' menu item. + pub fn new() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-new")) + .command(commands::NEW_FILE) + .hotkey(SysMods::Cmd, "n") + } + + /// The 'Open...' menu item. + pub fn open() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-open")) + .command(commands::SHOW_OPEN_PANEL.with(FileDialogOptions::default())) + .hotkey(SysMods::Cmd, "o") + } + + /// The 'Close' menu item. + pub fn close() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-close")) + .command(commands::CLOSE_WINDOW) + } + + /// The 'Save' menu item. + pub fn save() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-save")) + .command(commands::SAVE_FILE) + .hotkey(SysMods::Cmd, "s") + } + + /// The 'Save...' menu item. + /// + /// This is used if we need to show a dialog to select save location. + pub fn save_ellipsis() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-save-ellipsis")) + .command(commands::SHOW_SAVE_PANEL.with(FileDialogOptions::default())) + .hotkey(SysMods::Cmd, "s") + } + + /// The 'Save as...' menu item. + pub fn save_as() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-save-as")) + .command(commands::SHOW_SAVE_PANEL.with(FileDialogOptions::default())) + .hotkey(SysMods::CmdShift, "S") + } + + /// The 'Print...' menu item. + pub fn print() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-print")) + .command(commands::PRINT) + .hotkey(SysMods::Cmd, "p") + } + + /// The 'Print Preview' menu item. + pub fn print_preview() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-print-preview")) + .command(commands::PRINT_PREVIEW) + } + + /// The 'Page Setup...' menu item. + pub fn page_setup() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-page-setup")) + .command(commands::PRINT_SETUP) + } + + /// The 'Exit' menu item. + pub fn exit() -> MenuItem { + MenuItem::new(LocalizedString::new("win-menu-file-exit")).command(commands::QUIT_APP) + } + } +} + +/// macOS. +pub mod mac { + use super::*; + + /// A basic macOS menu bar. + pub fn menu_bar() -> Menu { + Menu::new(LocalizedString::new("")) + .entry(application::default()) + .entry(file::default()) + } + + /// The application menu + pub mod application { + use super::*; + + /// The default Application menu. + pub fn default() -> Menu { + Menu::new(LocalizedString::new("macos-menu-application-menu")) + .entry(about()) + .separator() + .entry(preferences().enabled(false)) + .separator() + //.entry(MenuDesc::new(LocalizedString::new("macos-menu-services"))) + .entry(hide()) + .entry(hide_others()) + .entry(show_all().enabled(false)) + .separator() + .entry(quit()) + } + + /// The 'About App' menu item. + pub fn about() -> MenuItem { + MenuItem::new(LocalizedString::new("macos-menu-about-app")) + .command(commands::SHOW_ABOUT) + } + + /// The preferences menu item. + pub fn preferences() -> MenuItem { + MenuItem::new(LocalizedString::new("macos-menu-preferences")) + .command(commands::SHOW_PREFERENCES) + .hotkey(SysMods::Cmd, ",") + } + + /// The 'Hide' builtin menu item. + pub fn hide() -> MenuItem { + MenuItem::new(LocalizedString::new("macos-menu-hide-app")) + .command(commands::HIDE_APPLICATION) + .hotkey(SysMods::Cmd, "h") + } + + /// The 'Hide Others' builtin menu item. + pub fn hide_others() -> MenuItem { + MenuItem::new(LocalizedString::new("macos-menu-hide-others")) + .command(commands::HIDE_OTHERS) + .hotkey(SysMods::AltCmd, "h") + } + + /// The 'show all' builtin menu item + //FIXME: this doesn't work + pub fn show_all() -> MenuItem { + MenuItem::new(LocalizedString::new("macos-menu-show-all")).command(commands::SHOW_ALL) + } + + /// The 'Quit' menu item. + pub fn quit() -> MenuItem { + MenuItem::new(LocalizedString::new("macos-menu-quit-app")) + .command(commands::QUIT_APP) + .hotkey(SysMods::Cmd, "q") + } + } + /// The file menu. + pub mod file { + use super::*; + use crate::FileDialogOptions; + + /// A default file menu. + /// + /// This will not be suitable for many applications; you should + /// build the menu you need manually, using the items defined here + /// where appropriate. + pub fn default() -> Menu { + Menu::new(LocalizedString::new("common-menu-file-menu")) + .entry(new_file()) + .entry(open_file()) + // open recent? + .separator() + .entry(close()) + .entry(save().enabled(false)) + .entry(save_as().enabled(false)) + // revert to saved? + .separator() + .entry(page_setup().enabled(false)) + .entry(print().enabled(false)) + } + + /// The 'New Window' item. + /// + /// Note: depending on context, apps might show 'New', 'New Window', + /// 'New File', or 'New...' (where the last indicates that the menu + /// item will open a prompt). You may want to create a custom + /// item to capture the intent of your menu, instead of using this one. + pub fn new_file() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-new")) + .command(commands::NEW_FILE) + .hotkey(SysMods::Cmd, "n") + } + + /// The 'Open...' menu item. Will display the system file-chooser. + pub fn open_file() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-open")) + .command(commands::SHOW_OPEN_PANEL.with(FileDialogOptions::default())) + .hotkey(SysMods::Cmd, "o") + } + + /// The 'Close' menu item. + pub fn close() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-close")) + .command(commands::CLOSE_WINDOW) + .hotkey(SysMods::Cmd, "w") + } + + /// The 'Save' menu item. + pub fn save() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-save")) + .command(commands::SAVE_FILE) + .hotkey(SysMods::Cmd, "s") + } + + /// The 'Save...' menu item. + /// + /// This is used if we need to show a dialog to select save location. + pub fn save_ellipsis() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-save-ellipsis")) + .command(commands::SHOW_SAVE_PANEL.with(FileDialogOptions::default())) + .hotkey(SysMods::Cmd, "s") + } + + /// The 'Save as...' + pub fn save_as() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-save-as")) + .command(commands::SHOW_SAVE_PANEL.with(FileDialogOptions::default())) + .hotkey(SysMods::CmdShift, "S") + } + + /// The 'Page Setup...' menu item. + pub fn page_setup() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-page-setup")) + .command(commands::PRINT_SETUP) + .hotkey(SysMods::CmdShift, "P") + } + + /// The 'Print...' menu item. + pub fn print() -> MenuItem { + MenuItem::new(LocalizedString::new("common-menu-file-print")) + .command(commands::PRINT) + .hotkey(SysMods::Cmd, "p") + } + } +} diff --git a/druid/src/win_handler.rs b/druid/src/win_handler.rs index 40a472fe1f..653e562e07 100644 --- a/druid/src/win_handler.rs +++ b/druid/src/win_handler.rs @@ -29,11 +29,11 @@ use crate::shell::{ use crate::app_delegate::{AppDelegate, DelegateCtx}; use crate::core::CommandQueue; use crate::ext_event::{ExtEventHost, ExtEventSink}; -use crate::menu::ContextMenu; +use crate::menu::{ContextMenu, MenuItemId, MenuManager}; use crate::window::{ImeUpdateFn, Window}; use crate::{ - Command, Data, Env, Event, Handled, InternalEvent, KeyEvent, MenuDesc, PlatformError, Selector, - Target, TimerToken, WidgetId, WindowDesc, WindowId, + Command, Data, Env, Event, Handled, InternalEvent, KeyEvent, PlatformError, Selector, Target, + TimerToken, WidgetId, WindowDesc, WindowId, }; use crate::app::{PendingWindow, WindowConfig}; @@ -93,7 +93,10 @@ struct Inner { windows: Windows, /// the application-level menu, only set on macos and only if there /// are no open windows. - root_menu: Option>, + root_menu: Option>, + /// The id of the most-recently-focused window that has a menu. On macOS, this + /// is the window that's currently in charge of the app menu. + menu_window: Option, pub(crate) env: Env, pub(crate) data: T, ime_focus_change: Option>, @@ -160,6 +163,7 @@ impl AppState { command_queue: VecDeque::new(), file_dialogs: HashMap::new(), root_menu: None, + menu_window: None, ext_event_host, data, env, @@ -176,14 +180,20 @@ impl AppState { } impl Inner { - fn get_menu_cmd(&self, window_id: Option, cmd_id: u32) -> Option { + fn handle_menu_cmd(&mut self, cmd_id: MenuItemId, window_id: Option) { + let queue = &mut self.command_queue; + let data = &mut self.data; + let env = &self.env; match window_id { - Some(id) => self.windows.get(id).and_then(|w| w.get_menu_cmd(cmd_id)), + Some(id) => self + .windows + .get_mut(id) + .map(|w| w.menu_cmd(queue, cmd_id, data, env)), None => self .root_menu - .as_ref() - .and_then(|m| m.command_for_id(cmd_id)), - } + .as_mut() + .map(|m| m.event(queue, None, cmd_id, data, env)), + }; } fn append_command(&mut self, cmd: Command) { @@ -347,11 +357,6 @@ impl Inner { match cmd.target() { Target::Window(id) => { - // first handle special window-level events - if cmd.is(sys_cmd::SET_MENU) { - self.set_menu(id, &cmd); - return Handled::Yes; - } if cmd.is(sys_cmd::SHOW_CONTEXT_MENU) { self.show_context_menu(id, &cmd); return Handled::Yes; @@ -433,29 +438,15 @@ impl Inner { } } - fn set_menu(&mut self, window_id: WindowId, cmd: &Command) { - if let Some(win) = self.windows.get_mut(window_id) { - match cmd - .get_unchecked(sys_cmd::SET_MENU) - .downcast_ref::>() - { - Some(menu) => win.set_menu(menu.clone(), &self.data, &self.env), - None => panic!( - "{} command must carry a MenuDesc.", - sys_cmd::SET_MENU - ), - } - } - } - fn show_context_menu(&mut self, window_id: WindowId, cmd: &Command) { if let Some(win) = self.windows.get_mut(window_id) { match cmd .get_unchecked(sys_cmd::SHOW_CONTEXT_MENU) - .downcast_ref::>() + .take() + .and_then(|b| b.downcast::>().ok()) { - Some(ContextMenu { menu, location }) => { - win.show_context_menu(menu.to_owned(), *location, &self.data, &self.env) + Some(menu) => { + win.show_context_menu(menu.build, menu.location, &self.data, &self.env) } None => panic!( "{} command must carry a ContextMenu.", @@ -477,6 +468,22 @@ impl Inner { let f = Box::new(move || handle.set_focused_text_field(focus_change)); self.ime_focus_change = Some(f); } + + #[cfg(not(target_os = "macos"))] + window.update_menu(&self.data, &self.env); + } + + #[cfg(target_os = "macos")] + { + let windows = &mut self.windows; + let window = self.menu_window.and_then(|w| windows.get_mut(w)); + if let Some(window) = window { + window.update_menu(&self.data, &self.env); + } else if let Some(root_menu) = &mut self.root_menu { + if let Some(new_menu) = root_menu.update(None, &self.data, &self.env) { + self.app.set_menu(new_menu); + } + } } self.invalidate_and_finalize(); } @@ -517,14 +524,16 @@ impl Inner { .release_ime_lock(token) } - #[cfg(target_os = "macos")] fn window_got_focus(&mut self, window_id: WindowId) { if let Some(win) = self.windows.get_mut(window_id) { + if win.menu.is_some() { + self.menu_window = Some(window_id); + } + + #[cfg(target_os = "macos")] win.macos_update_app_menu(&self.data, &self.env) } } - #[cfg(not(target_os = "macos"))] - fn window_got_focus(&mut self, _: WindowId) {} } impl DruidHandler { @@ -630,16 +639,9 @@ impl AppState { /// the `window_id` will be `Some(_)`, otherwise (such as if no window /// is open but a menu exists, as on macOS) it will be `None`. fn handle_system_cmd(&mut self, cmd_id: u32, window_id: Option) { - let cmd = self.inner.borrow().get_menu_cmd(window_id, cmd_id); - match cmd { - Some(cmd) => { - let default_target = window_id.map(Into::into).unwrap_or(Target::Global); - self.inner - .borrow_mut() - .append_command(cmd.default_to(default_target)) - } - None => tracing::warn!("No command for menu id {}", cmd_id), - } + self.inner + .borrow_mut() + .handle_menu_cmd(MenuItemId::new(cmd_id), window_id); self.process_commands(); self.inner.borrow_mut().do_update(); } @@ -863,7 +865,7 @@ impl AppState { let platform_menu = pending .menu .as_mut() - .map(|m| m.build_window_menu(&data, &env)); + .map(|m| m.initialize(Some(id), &data, &env)); if let Some(menu) = platform_menu { builder.set_menu(menu); } diff --git a/druid/src/window.rs b/druid/src/window.rs index 01953156c9..cb44c537ab 100644 --- a/druid/src/window.rs +++ b/druid/src/window.rs @@ -27,14 +27,15 @@ use crate::shell::{text::InputHandler, Counter, Cursor, Region, TextFieldToken, use crate::app::{PendingWindow, WindowSizePolicy}; use crate::contexts::ContextState; use crate::core::{CommandQueue, FocusChange, WidgetState}; +use crate::menu::{MenuItemId, MenuManager}; use crate::text::TextFieldRegistration; use crate::util::ExtendDrain; use crate::widget::LabelText; use crate::win_handler::RUN_COMMANDS_TOKEN; use crate::{ - BoxConstraints, Command, Data, Env, Event, EventCtx, ExtEventSink, Handled, InternalEvent, - InternalLifeCycle, LayoutCtx, LifeCycle, LifeCycleCtx, MenuDesc, PaintCtx, Point, Size, - TimerToken, UpdateCtx, Widget, WidgetId, WidgetPod, + BoxConstraints, Data, Env, Event, EventCtx, ExtEventSink, Handled, InternalEvent, + InternalLifeCycle, LayoutCtx, LifeCycle, LifeCycleCtx, Menu, PaintCtx, Point, Size, TimerToken, + UpdateCtx, Widget, WidgetId, WidgetPod, }; /// FIXME: Replace usage with Color::TRANSPARENT on next Piet release @@ -54,8 +55,8 @@ pub struct Window { size_policy: WindowSizePolicy, size: Size, invalid: Region, - pub(crate) menu: Option>, - pub(crate) context_menu: Option>, + pub(crate) menu: Option>, + pub(crate) context_menu: Option<(MenuManager, Point)>, // This will be `Some` whenever the most recently displayed frame was an animation frame. pub(crate) last_anim: Option, pub(crate) last_mouse_pos: Option, @@ -115,30 +116,40 @@ impl Window { widget_id == self.root.id() || self.root.state().children.may_contain(&widget_id) } - pub(crate) fn set_menu(&mut self, mut menu: MenuDesc, data: &T, env: &Env) { - let platform_menu = menu.build_window_menu(data, env); - self.handle.set_menu(platform_menu); - self.menu = Some(menu); + pub(crate) fn menu_cmd( + &mut self, + queue: &mut CommandQueue, + cmd_id: MenuItemId, + data: &mut T, + env: &Env, + ) { + if let Some(menu) = &mut self.menu { + menu.event(queue, Some(self.id), cmd_id, data, env); + } + if let Some((menu, _)) = &mut self.context_menu { + menu.event(queue, Some(self.id), cmd_id, data, env); + } } pub(crate) fn show_context_menu( &mut self, - mut menu: MenuDesc, + menu: impl FnMut(&T, &Env) -> Menu + 'static, point: Point, data: &T, env: &Env, ) { - let platform_menu = menu.build_popup_menu(data, env); - self.handle.show_context_menu(platform_menu, point); - self.context_menu = Some(menu); + let mut manager = MenuManager::new_for_popup(menu); + self.handle + .show_context_menu(manager.initialize(Some(self.id), data, env), point); + self.context_menu = Some((manager, point)); } /// On macos we need to update the global application menu to be the menu /// for the current window. #[cfg(target_os = "macos")] pub(crate) fn macos_update_app_menu(&mut self, data: &T, env: &Env) { - if let Some(menu) = self.menu.as_mut().map(|m| m.build_window_menu(data, env)) { - self.handle.set_menu(menu); + if let Some(menu) = self.menu.as_mut() { + self.handle.set_menu(menu.refresh(data, env)); } } @@ -542,11 +553,17 @@ impl Window { } } - pub(crate) fn get_menu_cmd(&self, cmd_id: u32) -> Option { - self.context_menu - .as_ref() - .and_then(|m| m.command_for_id(cmd_id)) - .or_else(|| self.menu.as_ref().and_then(|m| m.command_for_id(cmd_id))) + pub(crate) fn update_menu(&mut self, data: &T, env: &Env) { + if let Some(menu) = &mut self.menu { + if let Some(new_menu) = menu.update(Some(self.id), data, env) { + self.handle.set_menu(new_menu); + } + } + if let Some((menu, point)) = &mut self.context_menu { + if let Some(new_menu) = menu.update(Some(self.id), data, env) { + self.handle.show_context_menu(new_menu, *point); + } + } } pub(crate) fn get_ime_handler( From 8e66c59f24e66f2551e4f0ca82244d94e0e5c130 Mon Sep 17 00:00:00 2001 From: Joe Neeman Date: Thu, 18 Mar 2021 22:24:31 -0500 Subject: [PATCH 2/4] Simplify context menus. --- druid/examples/multiwin.rs | 9 ++++---- druid/src/contexts.rs | 10 +++++---- druid/src/lib.rs | 2 +- druid/src/menu/mod.rs | 42 +++++++++++++++++--------------------- druid/src/win_handler.rs | 2 +- druid/src/window.rs | 8 +------- 6 files changed, 32 insertions(+), 41 deletions(-) diff --git a/druid/examples/multiwin.rs b/druid/examples/multiwin.rs index 0e564229ac..b4e47b17d6 100644 --- a/druid/examples/multiwin.rs +++ b/druid/examples/multiwin.rs @@ -20,8 +20,8 @@ use druid::widget::{ }; use druid::Target::Global; use druid::{ - commands as sys_cmds, AppDelegate, AppLauncher, Application, Color, Command, ContextMenu, Data, - DelegateCtx, Handled, LocalizedString, Menu, MenuItem, Target, WindowDesc, WindowId, + commands as sys_cmds, AppDelegate, AppLauncher, Application, Color, Command, Data, DelegateCtx, + Handled, LocalizedString, Menu, MenuItem, Target, WindowDesc, WindowId, }; use tracing::info; @@ -137,8 +137,7 @@ impl> Controller for ContextMenuController { ) { match event { Event::MouseDown(ref mouse) if mouse.button.is_right() => { - let menu = ContextMenu::new(make_context_menu, mouse.pos); - ctx.show_context_menu(menu); + ctx.show_context_menu(make_context_menu(), mouse.pos); } _ => child.event(ctx, event, data, env), } @@ -220,7 +219,7 @@ fn make_menu(_: Option, state: &State, _: &Env) -> Menu { base.rebuild_on(|old_data, data, _env| old_data.menu_count != data.menu_count) } -fn make_context_menu(_state: &State, _env: &Env) -> Menu { +fn make_context_menu() -> Menu { Menu::empty() .entry( MenuItem::new(LocalizedString::new("Increment")) diff --git a/druid/src/contexts.rs b/druid/src/contexts.rs index 10f4d1d213..12fd0b67bd 100644 --- a/druid/src/contexts.rs +++ b/druid/src/contexts.rs @@ -25,14 +25,15 @@ use tracing::{error, trace, warn}; use crate::core::{CommandQueue, CursorChange, FocusChange, WidgetState}; use crate::env::KeyLike; +use crate::menu::ContextMenu; use crate::piet::{Piet, PietText, RenderContext}; use crate::shell::text::Event as ImeInvalidation; use crate::shell::Region; use crate::text::{ImeHandlerRef, TextFieldRegistration}; use crate::{ - commands, sub_window::SubWindowDesc, widget::Widget, Affine, Command, ContextMenu, Cursor, - Data, Env, ExtEventSink, Insets, Notification, Point, Rect, SingleUse, Size, Target, - TimerToken, Vec2, WidgetId, WindowConfig, WindowDesc, WindowHandle, WindowId, + commands, sub_window::SubWindowDesc, widget::Widget, Affine, Command, Cursor, Data, Env, + ExtEventSink, Insets, Menu, Notification, Point, Rect, SingleUse, Size, Target, TimerToken, + Vec2, WidgetId, WindowConfig, WindowDesc, WindowHandle, WindowId, }; /// A macro for implementing methods on multiple contexts. @@ -521,9 +522,10 @@ impl EventCtx<'_, '_> { /// `T` must be the application's root `Data` type (the type provided to [`AppLauncher::launch`]). /// /// [`AppLauncher::launch`]: struct.AppLauncher.html#method.launch - pub fn show_context_menu(&mut self, menu: ContextMenu) { + pub fn show_context_menu(&mut self, menu: Menu, location: Point) { trace!("show_context_menu"); if self.state.root_app_data_type == TypeId::of::() { + let menu = ContextMenu { menu, location }; self.submit_command( commands::SHOW_CONTEXT_MENU .with(SingleUse::new(Box::new(menu))) diff --git a/druid/src/lib.rs b/druid/src/lib.rs index c836a05584..5d47514d33 100644 --- a/druid/src/lib.rs +++ b/druid/src/lib.rs @@ -204,7 +204,7 @@ pub use event::{Event, InternalEvent, InternalLifeCycle, LifeCycle}; pub use ext_event::{ExtEventError, ExtEventSink}; pub use lens::{Lens, LensExt}; pub use localization::LocalizedString; -pub use menu::{sys as platform_menus, ContextMenu, Menu, MenuItem}; +pub use menu::{sys as platform_menus, Menu, MenuItem}; pub use mouse::MouseEvent; pub use text::{ArcStr, FontDescriptor, TextLayout}; pub use util::Handled; diff --git a/druid/src/menu/mod.rs b/druid/src/menu/mod.rs index 24ad6f9cf9..3bb802468b 100644 --- a/druid/src/menu/mod.rs +++ b/druid/src/menu/mod.rs @@ -126,38 +126,27 @@ type MenuBuild = Box, &T, &Env) -> Menu>; /// This is for completely recreating the menus (for when you want to change the actual menu /// structure, rather than just, say, enabling or disabling entries). pub(crate) struct MenuManager { - build: MenuBuild, + // The function for rebuilding the menu. If this is `None` (which is the case for context + // menus), `menu` will always be `Some(..)`. + build: Option>, popup: bool, old_data: Option, menu: Option>, } /// A menu displayed as a pop-over. -pub struct ContextMenu { - pub(crate) build: Box Menu>, +pub(crate) struct ContextMenu { + pub(crate) menu: Menu, pub(crate) location: Point, } -impl ContextMenu { - /// Create a new [`ContextMenu`]. - pub fn new( - build: impl FnMut(&T, &Env) -> Menu + 'static, - location: Point, - ) -> ContextMenu { - ContextMenu { - build: Box::new(build), - location, - } - } -} - impl MenuManager { /// Create a new [`MenuManager`] for a title-bar menu. pub fn new( build: impl FnMut(Option, &T, &Env) -> Menu + 'static, ) -> MenuManager { MenuManager { - build: Box::new(build), + build: Some(Box::new(build)), popup: false, old_data: None, menu: None, @@ -165,12 +154,12 @@ impl MenuManager { } /// Create a new [`MenuManager`] for a context menu. - pub fn new_for_popup(mut build: impl FnMut(&T, &Env) -> Menu + 'static) -> MenuManager { + pub fn new_for_popup(menu: Menu) -> MenuManager { MenuManager { - build: Box::new(move |_, data, env| build(data, env)), + build: None, popup: true, old_data: None, - menu: None, + menu: Some(menu), } } @@ -206,7 +195,9 @@ impl MenuManager { /// Build an initial menu from the application data. pub fn initialize(&mut self, window: Option, data: &T, env: &Env) -> PlatformMenu { - self.menu = Some((self.build)(window, data, env)); + if let Some(build) = &mut self.build { + self.menu = Some((build)(window, data, env)); + } self.old_data = Some(data.clone()); self.refresh(data, env) } @@ -223,7 +214,11 @@ impl MenuManager { if let (Some(menu), Some(old_data)) = (self.menu.as_mut(), self.old_data.as_ref()) { let ret = match menu.update(old_data, data, env) { MenuUpdate::NeedsRebuild => { - self.menu = Some((self.build)(window, data, env)); + if let Some(build) = &mut self.build { + self.menu = Some((build)(window, data, env)); + } else { + tracing::warn!("tried to rebuild a context menu"); + } Some(self.refresh(data, env)) } MenuUpdate::NeedsRefresh => Some(self.refresh(data, env)), @@ -570,7 +565,8 @@ impl Menu { /// whereas refreshing involves tweaking the existing menu entries (e.g. enabling or disabling /// items). /// - /// If you do not provide a callback using this method, the menu will never get rebuilt. + /// If you do not provide a callback using this method, the menu will never get rebuilt. Also, + /// only window and application menus get rebuilt; context menus never do. /// /// [`refresh_on`]: self::Menu::refresh_on pub fn rebuild_on(mut self, rebuild: impl FnMut(&T, &T, &Env) -> bool + 'static) -> Self { diff --git a/druid/src/win_handler.rs b/druid/src/win_handler.rs index 653e562e07..48dc8364c2 100644 --- a/druid/src/win_handler.rs +++ b/druid/src/win_handler.rs @@ -446,7 +446,7 @@ impl Inner { .and_then(|b| b.downcast::>().ok()) { Some(menu) => { - win.show_context_menu(menu.build, menu.location, &self.data, &self.env) + win.show_context_menu(menu.menu, menu.location, &self.data, &self.env) } None => panic!( "{} command must carry a ContextMenu.", diff --git a/druid/src/window.rs b/druid/src/window.rs index cb44c537ab..b497c19315 100644 --- a/druid/src/window.rs +++ b/druid/src/window.rs @@ -131,13 +131,7 @@ impl Window { } } - pub(crate) fn show_context_menu( - &mut self, - menu: impl FnMut(&T, &Env) -> Menu + 'static, - point: Point, - data: &T, - env: &Env, - ) { + pub(crate) fn show_context_menu(&mut self, menu: Menu, point: Point, data: &T, env: &Env) { let mut manager = MenuManager::new_for_popup(menu); self.handle .show_context_menu(manager.initialize(Some(self.id), data, env), point); From f820621f211e494e2062c8d512ec48147f4827c3 Mon Sep 17 00:00:00 2001 From: Joe Neeman Date: Fri, 19 Mar 2021 13:34:43 -0500 Subject: [PATCH 3/4] Silence a warning. --- druid/src/win_handler.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/druid/src/win_handler.rs b/druid/src/win_handler.rs index 48dc8364c2..3930ca30f1 100644 --- a/druid/src/win_handler.rs +++ b/druid/src/win_handler.rs @@ -96,6 +96,7 @@ struct Inner { root_menu: Option>, /// The id of the most-recently-focused window that has a menu. On macOS, this /// is the window that's currently in charge of the app menu. + #[allow(unused_variables)] menu_window: Option, pub(crate) env: Env, pub(crate) data: T, From f554501bee8d6b5c249052f62c5fec311dbc21d2 Mon Sep 17 00:00:00 2001 From: Joe Neeman Date: Fri, 19 Mar 2021 13:37:49 -0500 Subject: [PATCH 4/4] Update changelog. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af373a3ffb..6a76f38595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ You can find its changes [documented below](#070---2021-01-01). - Switch to trace-based logging ([#1562] by [@PoignardAzur]) - Spacers in `Flex` are now implemented by calculating the space in `Flex` instead of creating a widget for it ([#1584] by [@JAicewizard]) - Padding is generic over child widget, impls WidgetWrapper ([#1634] by [@cmyr]) +- Menu support was rewritten with support for `Data` ([#1625] by [@jneem]) ### Deprecated @@ -638,6 +639,7 @@ Last release without a changelog :( [#1600]: https://github.com/linebender/druid/pull/1600 [#1606]: https://github.com/linebender/druid/pull/1606 [#1619]: https://github.com/linebender/druid/pull/1619 +[#1625]: https://github.com/linebender/druid/pull/1625 [#1634]: https://github.com/linebender/druid/pull/1634 [#1635]: https://github.com/linebender/druid/pull/1635 [#1636]: https://github.com/linebender/druid/pull/1636