From bb4516e377f28d7a9e03993ee0df391ead584c4f Mon Sep 17 00:00:00 2001 From: Timon Date: Sat, 29 Nov 2025 00:42:14 +0100 Subject: [PATCH] Desktop: Add app menu for Mac (#3428) * add mac app menu * review fixup * Remove "About Graphite" ellipsis, add "Show All", make it say "Quit Graphite" --------- Co-authored-by: Keavon Chambers --- desktop/src/app.rs | 51 +++++++++----- desktop/src/window.rs | 15 ++++ desktop/src/window/mac.rs | 12 ++++ desktop/src/window/mac/app.rs | 26 +++++-- desktop/src/window/mac/menu.rs | 10 +-- .../src/handle_desktop_wrapper_message.rs | 2 +- .../wrapper/src/intercept_frontend_message.rs | 39 ++++++----- desktop/wrapper/src/messages.rs | 11 +-- editor/src/dispatcher.rs | 1 + .../messages/app_window/app_window_message.rs | 13 ++-- .../app_window/app_window_message_handler.rs | 43 ++++++++---- .../src/messages/frontend/frontend_message.rs | 11 +-- .../messages/input_mapper/input_mappings.rs | 11 ++- .../menu_bar/menu_bar_message_handler.rs | 70 +++++++++++++++---- frontend/wasm/src/editor_api.rs | 8 +-- 15 files changed, 230 insertions(+), 93 deletions(-) diff --git a/desktop/src/app.rs b/desktop/src/app.rs index f02e52ef..6fd0b2ae 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -174,24 +174,6 @@ impl App { graphics_state.set_overlays_scene(scene); } } - DesktopFrontendMessage::MinimizeWindow => { - if let Some(window) = &self.window { - window.minimize(); - } - } - DesktopFrontendMessage::MaximizeWindow => { - if let Some(window) = &self.window { - window.toggle_maximize(); - } - } - DesktopFrontendMessage::DragWindow => { - if let Some(window) = &self.window { - window.start_drag(); - } - } - DesktopFrontendMessage::CloseWindow => { - self.app_event_scheduler.schedule(AppEvent::CloseWindow); - } DesktopFrontendMessage::PersistenceWriteDocument { id, document } => { self.persistent_data.write_document(id, document); } @@ -270,6 +252,39 @@ impl App { window.update_menu(entries); } } + DesktopFrontendMessage::WindowClose => { + self.app_event_scheduler.schedule(AppEvent::CloseWindow); + } + DesktopFrontendMessage::WindowMinimize => { + if let Some(window) = &self.window { + window.minimize(); + } + } + DesktopFrontendMessage::WindowMaximize => { + if let Some(window) = &self.window { + window.toggle_maximize(); + } + } + DesktopFrontendMessage::WindowDrag => { + if let Some(window) = &self.window { + window.start_drag(); + } + } + DesktopFrontendMessage::WindowHide => { + if let Some(window) = &self.window { + window.hide(); + } + } + DesktopFrontendMessage::WindowHideOthers => { + if let Some(window) = &self.window { + window.hide_others(); + } + } + DesktopFrontendMessage::WindowShowAll => { + if let Some(window) = &self.window { + window.show_all(); + } + } } } diff --git a/desktop/src/window.rs b/desktop/src/window.rs index 61692bc6..c98a90a2 100644 --- a/desktop/src/window.rs +++ b/desktop/src/window.rs @@ -11,6 +11,9 @@ pub(crate) trait NativeWindow { fn configure(attributes: WindowAttributes, event_loop: &dyn ActiveEventLoop) -> WindowAttributes; fn new(window: &dyn WinitWindow, app_event_scheduler: AppEventScheduler) -> Self; fn update_menu(&self, _entries: Vec) {} + fn hide(&self) {} + fn hide_others(&self) {} + fn show_all(&self) {} } #[cfg(target_os = "linux")] @@ -93,6 +96,18 @@ impl Window { let _ = self.winit_window.drag_window(); } + pub(crate) fn hide(&self) { + self.native_handle.hide(); + } + + pub(crate) fn hide_others(&self) { + self.native_handle.hide_others(); + } + + pub(crate) fn show_all(&self) { + self.native_handle.show_all(); + } + pub(crate) fn set_cursor(&self, cursor: winit::cursor::Cursor) { self.winit_window.set_cursor(cursor); } diff --git a/desktop/src/window/mac.rs b/desktop/src/window/mac.rs index 5688cbd3..4949159f 100644 --- a/desktop/src/window/mac.rs +++ b/desktop/src/window/mac.rs @@ -34,4 +34,16 @@ impl super::NativeWindow for NativeWindowImpl { fn update_menu(&self, entries: Vec) { self.menu.update(entries); } + + fn hide(&self) { + app::hide(); + } + + fn hide_others(&self) { + app::hide_others(); + } + + fn show_all(&self) { + app::show_all(); + } } diff --git a/desktop/src/window/mac/app.rs b/desktop/src/window/mac/app.rs index 36ca65c3..03d9ff57 100644 --- a/desktop/src/window/mac/app.rs +++ b/desktop/src/window/mac/app.rs @@ -2,12 +2,6 @@ use objc2::{ClassType, define_class, msg_send}; use objc2_app_kit::{NSApplication, NSEvent, NSEventType, NSResponder}; use objc2_foundation::NSObject; -pub(super) fn init() { - unsafe { - let _: &NSApplication = msg_send![GraphiteApplication::class(), sharedApplication]; - } -} - define_class!( #[unsafe(super(NSApplication, NSResponder, NSObject))] #[name = "GraphiteApplication"] @@ -25,3 +19,23 @@ define_class!( } } ); + +fn instance() -> objc2::rc::Retained { + unsafe { msg_send![GraphiteApplication::class(), sharedApplication] } +} + +pub(super) fn init() { + let _ = instance(); +} + +pub(super) fn hide() { + instance().hide(None); +} + +pub(super) fn hide_others() { + instance().hideOtherApplications(None); +} + +pub(super) fn show_all() { + instance().unhideAllApplications(None); +} diff --git a/desktop/src/window/mac/menu.rs b/desktop/src/window/mac/menu.rs index ba3a94df..d58c455b 100644 --- a/desktop/src/window/mac/menu.rs +++ b/desktop/src/window/mac/menu.rs @@ -43,11 +43,11 @@ impl Menu { let existing_entries = self.inner.items(); let mut new_entries_iter = new_entries.iter(); - let mut existing_entries_iter = existing_entries.iter().skip(1); // Skip first menu (app menu) + let mut existing_entries_iter = existing_entries.iter(); let incremental_update_ok = std::iter::from_fn(move || match (existing_entries_iter.next(), new_entries_iter.next()) { (Some(MenuItemKind::Submenu(old)), Some(MenuItemKind::Submenu(new))) if old.text() == new.text() => { - replace_children(old, 0, new.items()); + replace_children(old, new.items()); Some(true) } (None, None) => None, @@ -57,7 +57,7 @@ impl Menu { if !incremental_update_ok { // Fallback to full replace - replace_children(&self.inner, 1, new_entries); // Skip first menu (app menu) + replace_children(&self.inner, new_entries); } } } @@ -111,10 +111,10 @@ fn menu_id_to_u64(id: &MenuId) -> Option { u64::from_str_radix(&id.0, 16).ok() } -fn replace_children<'a, T: Into>>(menu: T, skip: usize, new_items: Vec) { +fn replace_children<'a, T: Into>>(menu: T, new_items: Vec) { let menu: MenuContainer = menu.into(); let items = menu.items(); - for item in items.iter().skip(skip) { + for item in items.iter() { menu.remove(menu_item_kind_to_dyn(item)).unwrap(); } let items = new_items.iter().map(|item| menu_item_kind_to_dyn(item)).collect::>(); diff --git a/desktop/wrapper/src/handle_desktop_wrapper_message.rs b/desktop/wrapper/src/handle_desktop_wrapper_message.rs index c649a433..50dab090 100644 --- a/desktop/wrapper/src/handle_desktop_wrapper_message.rs +++ b/desktop/wrapper/src/handle_desktop_wrapper_message.rs @@ -117,7 +117,7 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess Platform::Mac => AppWindowPlatform::Mac, Platform::Linux => AppWindowPlatform::Linux, }; - let message = AppWindowMessage::AppWindowUpdatePlatform { platform }; + let message = AppWindowMessage::UpdatePlatform { platform }; dispatcher.queue_editor_message(message); } DesktopWrapperMessage::UpdateMaximized { maximized } => { diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index dedc61d2..32979120 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -67,18 +67,6 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::TriggerVisitLink { url } => { dispatcher.respond(DesktopFrontendMessage::OpenUrl(url)); } - FrontendMessage::DragWindow => { - dispatcher.respond(DesktopFrontendMessage::DragWindow); - } - FrontendMessage::CloseWindow => { - dispatcher.respond(DesktopFrontendMessage::CloseWindow); - } - FrontendMessage::TriggerMinimizeWindow => { - dispatcher.respond(DesktopFrontendMessage::MinimizeWindow); - } - FrontendMessage::TriggerMaximizeWindow => { - dispatcher.respond(DesktopFrontendMessage::MaximizeWindow); - } FrontendMessage::UpdateViewportPhysicalBounds { x, y, width, height } => { dispatcher.respond(DesktopFrontendMessage::UpdateViewportPhysicalBounds { x, y, width, height }); } @@ -131,6 +119,27 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD return Some(FrontendMessage::UpdateMenuBarLayout { layout, layout_target }); } + FrontendMessage::WindowClose => { + dispatcher.respond(DesktopFrontendMessage::WindowClose); + } + FrontendMessage::WindowMinimize => { + dispatcher.respond(DesktopFrontendMessage::WindowMinimize); + } + FrontendMessage::WindowMaximize => { + dispatcher.respond(DesktopFrontendMessage::WindowMaximize); + } + FrontendMessage::WindowDrag => { + dispatcher.respond(DesktopFrontendMessage::WindowDrag); + } + FrontendMessage::WindowHide => { + dispatcher.respond(DesktopFrontendMessage::WindowHide); + } + FrontendMessage::WindowHideOthers => { + dispatcher.respond(DesktopFrontendMessage::WindowHideOthers); + } + FrontendMessage::WindowShowAll => { + dispatcher.respond(DesktopFrontendMessage::WindowShowAll); + } m => return Some(m), } None @@ -151,11 +160,7 @@ fn convert_menu_bar_entry_to_menu_item( }: &MenuBarEntry, ) -> Option { let id = action.widget_id.0; - let text = if label.is_empty() { - return None; - } else { - label.clone() - }; + let text = label.clone(); let enabled = !*disabled; if !children.0.is_empty() { diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index b6afbfbf..af8d4b59 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -32,10 +32,6 @@ pub enum DesktopFrontendMessage { height: f64, }, UpdateOverlays(vello::Scene), - MinimizeWindow, - MaximizeWindow, - DragWindow, - CloseWindow, PersistenceWriteDocument { id: DocumentId, document: Document, @@ -58,6 +54,13 @@ pub enum DesktopFrontendMessage { UpdateMenu { entries: Vec, }, + WindowClose, + WindowMinimize, + WindowMaximize, + WindowDrag, + WindowHide, + WindowHideOthers, + WindowShowAll, } pub enum DesktopWrapperMessage { diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 4018c0d0..0e9d7403 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -285,6 +285,7 @@ impl Dispatcher { pub fn collect_actions(&self) -> ActionList { // TODO: Reduce the number of heap allocations let mut list = Vec::new(); + list.extend(self.message_handlers.app_window_message_handler.actions()); list.extend(self.message_handlers.dialog_message_handler.actions()); list.extend(self.message_handlers.animation_message_handler.actions()); list.extend(self.message_handlers.input_preprocessor_message_handler.actions()); diff --git a/editor/src/messages/app_window/app_window_message.rs b/editor/src/messages/app_window/app_window_message.rs index 5c48089e..9ac40984 100644 --- a/editor/src/messages/app_window/app_window_message.rs +++ b/editor/src/messages/app_window/app_window_message.rs @@ -5,9 +5,12 @@ use super::app_window_message_handler::AppWindowPlatform; #[impl_message(Message, AppWindow)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum AppWindowMessage { - AppWindowMinimize, - AppWindowMaximize, - AppWindowUpdatePlatform { platform: AppWindowPlatform }, - AppWindowDrag, - AppWindowClose, + UpdatePlatform { platform: AppWindowPlatform }, + Close, + Minimize, + Maximize, + Drag, + Hide, + HideOthers, + ShowAll, } diff --git a/editor/src/messages/app_window/app_window_message_handler.rs b/editor/src/messages/app_window/app_window_message_handler.rs index 35d3d2b3..3b8b9235 100644 --- a/editor/src/messages/app_window/app_window_message_handler.rs +++ b/editor/src/messages/app_window/app_window_message_handler.rs @@ -11,28 +11,41 @@ pub struct AppWindowMessageHandler { impl MessageHandler for AppWindowMessageHandler { fn process_message(&mut self, message: AppWindowMessage, responses: &mut std::collections::VecDeque, _: ()) { match message { - AppWindowMessage::AppWindowMaximize => { - responses.add(FrontendMessage::TriggerMaximizeWindow); - } - AppWindowMessage::AppWindowMinimize => { - responses.add(FrontendMessage::TriggerMinimizeWindow); - } - AppWindowMessage::AppWindowUpdatePlatform { platform } => { + AppWindowMessage::UpdatePlatform { platform } => { self.platform = platform; responses.add(FrontendMessage::UpdatePlatform { platform: self.platform }); } - AppWindowMessage::AppWindowDrag => { - responses.add(FrontendMessage::DragWindow); + AppWindowMessage::Close => { + responses.add(FrontendMessage::WindowClose); } - AppWindowMessage::AppWindowClose => { - responses.add(FrontendMessage::CloseWindow); + AppWindowMessage::Minimize => { + responses.add(FrontendMessage::WindowMinimize); + } + AppWindowMessage::Maximize => { + responses.add(FrontendMessage::WindowMaximize); + } + AppWindowMessage::Drag => { + responses.add(FrontendMessage::WindowDrag); + } + AppWindowMessage::Hide => { + responses.add(FrontendMessage::WindowHide); + } + AppWindowMessage::HideOthers => { + responses.add(FrontendMessage::WindowHideOthers); + } + AppWindowMessage::ShowAll => { + responses.add(FrontendMessage::WindowShowAll); } } } - - fn actions(&self) -> ActionList { - actions!(AppWindowMessageDiscriminant;) - } + advertise_actions!(AppWindowMessageDiscriminant; + Close, + Minimize, + Maximize, + Drag, + Hide, + HideOthers, + ); } #[derive(PartialEq, Eq, Clone, Copy, Default, Debug, serde::Serialize, serde::Deserialize, specta::Type)] diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 0195180f..a674d70e 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -121,8 +121,6 @@ pub enum FrontendMessage { TriggerVisitLink { url: String, }, - TriggerMinimizeWindow, - TriggerMaximizeWindow, // Update prefix: give the frontend a new value or state for it to use UpdateActiveDocument { @@ -348,8 +346,6 @@ pub enum FrontendMessage { UpdateMaximized { maximized: bool, }, - DragWindow, - CloseWindow, UpdateViewportHolePunch { active: bool, }, @@ -365,4 +361,11 @@ pub enum FrontendMessage { #[derivative(Debug = "ignore", PartialEq = "ignore")] context: OverlayContext, }, + WindowClose, + WindowMinimize, + WindowMaximize, + WindowDrag, + WindowHide, + WindowHideOthers, + WindowShowAll, } diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index ce278ea7..2d959cdc 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -468,7 +468,7 @@ pub fn input_mappings() -> Mapping { // Sort `pointer_shake` sort(&mut pointer_shake); - Mapping { + let mut mapping = Mapping { key_up, key_down, key_up_no_repeat, @@ -477,7 +477,16 @@ pub fn input_mappings() -> Mapping { wheel_scroll, pointer_move, pointer_shake, + }; + + if cfg!(target_os = "macos") { + let remove: [&[&[MappingEntry; 0]; 0]; 0] = []; + let add = [entry!(KeyDown(KeyQ); modifiers=[Accel], action_dispatch=AppWindowMessage::Close)]; + + apply_mapping_patch(&mut mapping, remove, add); } + + mapping } /// Default mappings except that scrolling without modifier keys held down is bound to zooming instead of vertical panning diff --git a/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs index d2f9696f..9ecf016c 100644 --- a/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/portfolio/menu_bar/menu_bar_message_handler.rs @@ -50,12 +50,65 @@ impl LayoutHolder for MenuBarMessageHandler { let reset_node_definitions_on_open = self.reset_node_definitions_on_open; let make_path_editable_is_allowed = self.make_path_editable_is_allowed; + let about = MenuBarEntry { + #[cfg(not(target_os = "macos"))] + label: "About Graphite…".into(), + #[cfg(target_os = "macos")] + label: "About Graphite".into(), + icon: Some("GraphiteLogo".into()), + action: MenuBarEntry::create_action(|_| DialogMessage::RequestAboutGraphiteDialog.into()), + ..MenuBarEntry::default() + }; + let preferences = MenuBarEntry { + label: "Preferences…".into(), + icon: Some("Settings".into()), + shortcut: action_keys!(DialogMessageDiscriminant::RequestPreferencesDialog), + action: MenuBarEntry::create_action(|_| DialogMessage::RequestPreferencesDialog.into()), + ..MenuBarEntry::default() + }; + let menu_bar_entries = vec![ + #[cfg(not(target_os = "macos"))] MenuBarEntry { icon: Some("GraphiteLogo".into()), action: MenuBarEntry::create_action(|_| FrontendMessage::TriggerVisitLink { url: "https://graphite.rs".into() }.into()), ..Default::default() }, + #[cfg(target_os = "macos")] + MenuBarEntry::new_root( + "".into(), + false, + MenuBarEntryChildren(vec![ + vec![about], + vec![preferences], + vec![ + MenuBarEntry { + label: "Hide Graphite".into(), + shortcut: action_keys!(AppWindowMessageDiscriminant::Hide), + action: MenuBarEntry::create_action(|_| AppWindowMessage::Hide.into()), + ..MenuBarEntry::default() + }, + MenuBarEntry { + label: "Hide Others".into(), + shortcut: action_keys!(AppWindowMessageDiscriminant::HideOthers), + action: MenuBarEntry::create_action(|_| AppWindowMessage::HideOthers.into()), + ..MenuBarEntry::default() + }, + MenuBarEntry { + label: "Show All".into(), + shortcut: action_keys!(AppWindowMessageDiscriminant::ShowAll), + action: MenuBarEntry::create_action(|_| AppWindowMessage::ShowAll.into()), + ..MenuBarEntry::default() + }, + ], + vec![MenuBarEntry { + label: "Quit Graphite".into(), + shortcut: action_keys!(AppWindowMessageDiscriminant::Close), + action: MenuBarEntry::create_action(|_| AppWindowMessage::Close.into()), + ..MenuBarEntry::default() + }], + ]), + ), MenuBarEntry::new_root( "File".into(), false, @@ -137,13 +190,8 @@ impl LayoutHolder for MenuBarMessageHandler { ..MenuBarEntry::default() }, ], - vec![MenuBarEntry { - label: "Preferences…".into(), - icon: Some("Settings".into()), - shortcut: action_keys!(DialogMessageDiscriminant::RequestPreferencesDialog), - action: MenuBarEntry::create_action(|_| DialogMessage::RequestPreferencesDialog.into()), - ..MenuBarEntry::default() - }], + #[cfg(not(target_os = "macos"))] + vec![preferences], ]), ), MenuBarEntry::new_root( @@ -633,12 +681,8 @@ impl LayoutHolder for MenuBarMessageHandler { "Help".into(), false, MenuBarEntryChildren(vec![ - vec![MenuBarEntry { - label: "About Graphite…".into(), - icon: Some("GraphiteLogo".into()), - action: MenuBarEntry::create_action(|_| DialogMessage::RequestAboutGraphiteDialog.into()), - ..MenuBarEntry::default() - }], + #[cfg(not(target_os = "macos"))] + vec![about], vec![ MenuBarEntry { label: "Donate to Graphite".into(), diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs index 94d17986..45678b54 100644 --- a/frontend/wasm/src/editor_api.rs +++ b/frontend/wasm/src/editor_api.rs @@ -277,28 +277,28 @@ impl EditorHandle { /// Minimizes the application window to the taskbar or dock #[wasm_bindgen(js_name = appWindowMinimize)] pub fn app_window_minimize(&self) { - let message = AppWindowMessage::AppWindowMinimize; + let message = AppWindowMessage::Minimize; self.dispatch(message); } /// Toggles minimizing or restoring down the application window #[wasm_bindgen(js_name = appWindowMaximize)] pub fn app_window_maximize(&self) { - let message = AppWindowMessage::AppWindowMaximize; + let message = AppWindowMessage::Maximize; self.dispatch(message); } /// Closes the application window #[wasm_bindgen(js_name = appWindowClose)] pub fn app_window_close(&self) { - let message = AppWindowMessage::AppWindowClose; + let message = AppWindowMessage::Close; self.dispatch(message); } /// Drag the application window #[wasm_bindgen(js_name = appWindowDrag)] pub fn app_window_start_drag(&self) { - let message = AppWindowMessage::AppWindowDrag; + let message = AppWindowMessage::Drag; self.dispatch(message); }