Skip to main content

ferroui_native/
ferro_native_menu_exporter.rs

1//! Exports `NativeMenu` trees to the system: the application menu, the
2//! menu of a window, of a tray icon and of the dock.
3
4use crate::ferro_native_platform_extensions::MacOSPlatformOptions;
5use crate::frn_menu::{FrnMenu, IMenuFactory};
6use crate::helpers::ComResultExt;
7use crate::interop::*;
8use crate::mac_os_native_menu_commands::MacOSNativeMenuCommands;
9use ferroui_base::input::{Key, KeyGesture, KeyModifiers};
10use ferroui_base::reactive::{Disposable, IDisposable};
11use ferroui_base::threading::{Dispatcher, DispatcherPriority};
12use ferroui_base::{FerroLocator, LocatorExtensions, Ref};
13use ferroui_controls::platform::{INativeMenuExporter, ITopLevelNativeMenuExporter};
14use ferroui_controls::{Application, NativeDock, NativeMenu, NativeMenuItem, NativeMenuItemSeparator, WindowBase};
15use ferroui_dialogs::AboutFerroDialog;
16use ferroui_microcom::ComPtr;
17use std::cell::{Cell, RefCell};
18use std::rc::{Rc, Weak};
19
20/// What a menu is exported to.
21pub(crate) enum MenuTarget {
22    Application,
23    Window(ComPtr<IFrnWindow>),
24    TrayIcon(ComPtr<IFrnTrayIcon>),
25    Dock,
26}
27
28/// Exports a `NativeMenu` to one target and keeps the native menu in sync.
29pub struct FerroNativeMenuExporter {
30    weak_self: Weak<FerroNativeMenuExporter>,
31    factory: Rc<dyn IMenuFactory>,
32    target: MenuTarget,
33    reset_queued: Cell<bool>,
34    exported: Cell<bool>,
35    menu: RefCell<Option<Ref<NativeMenu>>>,
36    native_menu: RefCell<Option<Rc<FrnMenu>>>,
37    application_commands: Option<ComPtr<IFrnApplicationCommands>>,
38    /// The subscription to the dock menu of the application.
39    dock_menu_subscription: RefCell<Option<Rc<dyn IDisposable>>>,
40}
41
42fn mac_options() -> MacOSPlatformOptions {
43    FerroLocator::current().get_service::<MacOSPlatformOptions>().as_deref().cloned().unwrap_or_default()
44}
45
46impl FerroNativeMenuExporter {
47    fn new(
48        factory: Rc<dyn IMenuFactory>,
49        target: MenuTarget,
50        reset_queued: bool,
51        application_commands: Option<ComPtr<IFrnApplicationCommands>>,
52    ) -> Rc<FerroNativeMenuExporter> {
53        Rc::new_cyclic(|weak_self| FerroNativeMenuExporter {
54            weak_self: weak_self.clone(),
55            factory,
56            target,
57            reset_queued: Cell::new(reset_queued),
58            exported: Cell::new(false),
59            menu: RefCell::new(None),
60            native_menu: RefCell::new(None),
61            application_commands,
62            dock_menu_subscription: RefCell::new(None),
63        })
64    }
65
66    fn with_commands(factory: &ComPtr<IFerroNativeFactory>, target: MenuTarget) -> Rc<FerroNativeMenuExporter> {
67        let application_commands = factory.create_application_commands().check();
68        let this = Self::new(Rc::new(factory.clone()), target, true, application_commands);
69
70        this.do_layout_reset(false);
71        this
72    }
73
74    /// The exporter of the menu of a window.
75    pub(crate) fn for_window(
76        native_window: ComPtr<IFrnWindow>,
77        factory: &ComPtr<IFerroNativeFactory>,
78    ) -> Rc<FerroNativeMenuExporter> {
79        Self::with_commands(factory, MenuTarget::Window(native_window))
80    }
81
82    /// The exporter of the application menu.
83    pub(crate) fn for_application(factory: &ComPtr<IFerroNativeFactory>) -> Rc<FerroNativeMenuExporter> {
84        Self::with_commands(factory, MenuTarget::Application)
85    }
86
87    /// The exporter of the menu of a tray icon.
88    pub(crate) fn for_tray_icon(
89        tray_icon: ComPtr<IFrnTrayIcon>,
90        factory: &ComPtr<IFerroNativeFactory>,
91    ) -> Rc<FerroNativeMenuExporter> {
92        Self::with_commands(factory, MenuTarget::TrayIcon(tray_icon))
93    }
94
95    /// The exporter of the dock menu: follows the dock menu of the
96    /// application.
97    pub(crate) fn for_dock(factory: &ComPtr<IFerroNativeFactory>) -> Rc<FerroNativeMenuExporter> {
98        Self::for_dock_with(Rc::new(factory.clone()))
99    }
100
101    pub(crate) fn for_dock_with(factory: Rc<dyn IMenuFactory>) -> Rc<FerroNativeMenuExporter> {
102        let this = Self::new(factory, MenuTarget::Dock, false, None);
103
104        if mac_options().disable_native_menus {
105            return this;
106        }
107
108        let weak = Rc::downgrade(&this);
109        let subscription = NativeDock::menu_property().changed().subscribe(move |args| {
110            if args.sender().is::<Application>() {
111                if let Some(this) = weak.upgrade() {
112                    this.set_native_menu(args.get_new_value::<Option<Ref<NativeMenu>>>());
113                }
114            }
115        });
116        *this.dock_menu_subscription.borrow_mut() = Some(subscription);
117
118        if let Some(app) = Application::current() {
119            if let Some(dock_menu) = NativeDock::get_menu(&app) {
120                this.set_native_menu(Some(dock_menu));
121            }
122        }
123
124        this
125    }
126
127    pub(crate) fn update_if_needed(&self) {
128        if self.reset_queued.get() {
129            self.do_layout_reset(false);
130        }
131    }
132
133    fn create_default_app_menu() -> Ref<NativeMenu> {
134        let result = NativeMenu::new();
135
136        let about_item = NativeMenuItem::with_header("About FerroUI");
137
138        // The subscription lives as long as the item.
139        let _ = about_item.click(|_| {
140            Dispatcher::ui_thread().invoke_async_task_local(|| async {
141                let dialog = AboutFerroDialog::new();
142
143                let main_window = Application::current()
144                    .and_then(|app| app.application_lifetime())
145                    .and_then(|lifetime| lifetime.as_classic_desktop_style_application_lifetime().and_then(|d| d.main_window()))
146                    .filter(|main_window| main_window.is_visible());
147                match main_window {
148                    Some(main_window) => {
149                        let _ = dialog.show_dialog(&main_window).await;
150                    }
151                    None => WindowBase::show(&dialog),
152                }
153            });
154        });
155
156        result.add(about_item);
157
158        result
159    }
160
161    fn populate_standard_osx_menu_items(&self, app_menu: &Ref<NativeMenu>) {
162        app_menu.add(NativeMenuItemSeparator::new());
163
164        let services_menu = NativeMenuItem::with_header("Services");
165        let services_sub_menu = NativeMenu::new();
166        services_sub_menu.set_value(MacOSNativeMenuCommands::is_services_submenu_property(), true);
167        services_menu.set_menu(Some(services_sub_menu));
168
169        app_menu.add(services_menu);
170
171        app_menu.add(NativeMenuItemSeparator::new());
172
173        let application_name =
174            Application::current().and_then(|app| app.name()).unwrap_or_else(|| "Application".to_string());
175        let hide_item = NativeMenuItem::with_header(&format!("Hide {application_name}"));
176        hide_item.set_gesture(Some(KeyGesture::new(Key::H, KeyModifiers::META)));
177
178        let commands = self.application_commands.clone();
179        let _ = hide_item.click(move |_| {
180            if let Some(commands) = &commands {
181                commands.hide_app().check();
182            }
183        });
184
185        app_menu.add(hide_item);
186
187        let hide_others_item = NativeMenuItem::with_header("Hide Others");
188        hide_others_item.set_gesture(Some(KeyGesture::new(Key::Q, KeyModifiers::META | KeyModifiers::ALT)));
189        let commands = self.application_commands.clone();
190        let _ = hide_others_item.click(move |_| {
191            if let Some(commands) = &commands {
192                commands.hide_others().check();
193            }
194        });
195        app_menu.add(hide_others_item);
196
197        let show_all_item = NativeMenuItem::with_header("Show All");
198        let commands = self.application_commands.clone();
199        let _ = show_all_item.click(move |_| {
200            if let Some(commands) = &commands {
201                commands.show_all().check();
202            }
203        });
204
205        app_menu.add(show_all_item);
206
207        app_menu.add(NativeMenuItemSeparator::new());
208
209        let quit_item = NativeMenuItem::with_header("Quit");
210        quit_item.set_gesture(Some(KeyGesture::new(Key::Q, KeyModifiers::META)));
211        let _ = quit_item.click(|_| {
212            let lifetime = Application::current().and_then(|app| app.application_lifetime());
213            let Some(lifetime) = lifetime else {
214                return;
215            };
216            if let Some(lifetime) = lifetime.as_classic_desktop_style_application_lifetime() {
217                lifetime.try_shutdown(0);
218            } else if let Some(controlled_lifetime) = lifetime.as_controlled_application_lifetime() {
219                controlled_lifetime.shutdown(0);
220            }
221        });
222
223        app_menu.add(quit_item);
224    }
225
226    fn do_layout_reset(&self, force_update: bool) {
227        if mac_options().disable_native_menus {
228            return;
229        }
230
231        if self.reset_queued.get() || force_update {
232            self.reset_queued.set(false);
233
234            let menu = self.menu.borrow().clone();
235            match &self.target {
236                MenuTarget::Application => {
237                    let app = Application::current();
238                    let app_menu = app.as_ref().and_then(|app| NativeMenu::get_menu(app));
239
240                    let app_menu = match app_menu {
241                        Some(app_menu) => app_menu,
242                        None => {
243                            let app_menu = Self::create_default_app_menu();
244
245                            if let Some(app) = &app {
246                                NativeMenu::set_menu(app, Some(app_menu.clone()));
247                            }
248                            app_menu
249                        }
250                    };
251
252                    self.set_app_menu(&app_menu);
253                }
254
255                MenuTarget::Window(native_window) => {
256                    if let Some(menu) = menu {
257                        if self.ensure_native_menu(&menu) {
258                            let native_menu = self.native_menu.borrow().clone().expect("just created");
259                            native_window.set_main_menu(Some(native_menu.native())).check();
260                        }
261                    }
262                }
263
264                MenuTarget::TrayIcon(tray_icon) => {
265                    if let Some(menu) = menu {
266                        if self.ensure_native_menu(&menu) {
267                            let native_menu = self.native_menu.borrow().clone().expect("just created");
268                            tray_icon.set_menu(Some(native_menu.native())).check();
269                        }
270                    }
271                }
272
273                MenuTarget::Dock => {
274                    if let Some(menu) = menu {
275                        if self.ensure_native_menu(&menu) {
276                            let native_menu = self.native_menu.borrow().clone().expect("just created");
277                            self.factory.set_dock_menu(native_menu.native());
278                        }
279                    }
280                }
281            }
282
283            self.exported.set(true);
284        }
285    }
286
287    pub(crate) fn queue_reset(&self) {
288        if self.reset_queued.get() {
289            return;
290        }
291        self.reset_queued.set(true);
292        let this = self.weak_self.clone();
293        Dispatcher::ui_thread().post_local(
294            move || {
295                if let Some(this) = this.upgrade() {
296                    this.do_layout_reset(false);
297                }
298            },
299            DispatcherPriority::BACKGROUND,
300        );
301    }
302
303    fn this(&self) -> Rc<FerroNativeMenuExporter> {
304        self.weak_self.upgrade().expect("the exporter is alive while it is borrowed")
305    }
306
307    /// Creates the native menu for `menu` on first use and updates it;
308    /// returns whether it was just created (and still has to be handed to
309    /// its target).
310    fn ensure_native_menu(&self, menu: &Ref<NativeMenu>) -> bool {
311        let mut set_menu = false;
312
313        let native_menu = self.native_menu.borrow().clone();
314        let native_menu = match native_menu {
315            Some(native_menu) => native_menu,
316            None => {
317                let native_menu = FrnMenu::create(&*self.factory);
318
319                native_menu.initialize(&self.this(), menu, Some(""));
320                *self.native_menu.borrow_mut() = Some(native_menu.clone());
321
322                set_menu = true;
323                native_menu
324            }
325        };
326
327        native_menu.update(&*self.factory, menu);
328
329        set_menu
330    }
331
332    /// Exports the application menu: `menu` becomes the submenu of the
333    /// first item of the menu bar.
334    fn set_app_menu(&self, menu: &Ref<NativeMenu>) {
335        let menu_item = menu.parent();
336
337        let app_menu_holder = menu_item.as_ref().and_then(|menu_item| menu_item.parent());
338
339        let menu_item = menu_item.unwrap_or_else(NativeMenuItem::new);
340
341        let app_menu_holder = app_menu_holder.unwrap_or_else(|| {
342            let app_menu_holder = NativeMenu::new();
343
344            app_menu_holder.add(menu_item.clone());
345            app_menu_holder
346        });
347
348        menu_item.set_menu(Some(menu.clone()));
349
350        let mut set_menu = false;
351
352        let native_menu = self.native_menu.borrow().clone();
353        let native_menu = match native_menu {
354            Some(native_menu) => native_menu,
355            None => {
356                let native_menu = FrnMenu::create(&*self.factory);
357
358                native_menu.initialize(&self.this(), &app_menu_holder, Some(""));
359                *self.native_menu.borrow_mut() = Some(native_menu.clone());
360
361                if !mac_options().disable_default_application_menu_items {
362                    self.populate_standard_osx_menu_items(menu);
363                }
364
365                set_menu = true;
366                native_menu
367            }
368        };
369
370        native_menu.update(&*self.factory, &app_menu_holder);
371
372        if set_menu {
373            self.factory.set_app_menu(native_menu.native());
374        }
375    }
376
377    #[cfg(test)]
378    pub(crate) fn native_menu(&self) -> Option<Rc<FrnMenu>> {
379        self.native_menu.borrow().clone()
380    }
381
382    #[cfg(test)]
383    pub(crate) fn for_application_with(factory: Rc<dyn IMenuFactory>) -> Rc<FerroNativeMenuExporter> {
384        let this = Self::new(factory, MenuTarget::Application, true, None);
385        this.do_layout_reset(false);
386        this
387    }
388}
389
390impl INativeMenuExporter for FerroNativeMenuExporter {
391    fn set_native_menu(&self, menu: Option<Ref<NativeMenu>>) {
392        if matches!(self.target, MenuTarget::Dock) {
393            let has_menu = menu.is_some();
394            *self.menu.borrow_mut() = menu;
395
396            if has_menu {
397                self.do_layout_reset(true);
398            }
399        } else {
400            *self.menu.borrow_mut() = Some(menu.unwrap_or_else(NativeMenu::new));
401            self.do_layout_reset(true);
402        }
403    }
404}
405
406impl ITopLevelNativeMenuExporter for FerroNativeMenuExporter {
407    fn is_native_menu_exported(&self) -> bool {
408        self.exported.get()
409    }
410
411    /// Never raised, as in the reference implementation.
412    fn on_is_native_menu_exported_changed(&self, _handler: Rc<dyn Fn()>) -> Rc<dyn IDisposable> {
413        Disposable::empty()
414    }
415}
416
417impl Drop for FerroNativeMenuExporter {
418    fn drop(&mut self) {
419        if let Some(subscription) = self.dock_menu_subscription.borrow_mut().take() {
420            subscription.dispose();
421        }
422    }
423}