egui icon indicating copy to clipboard operation
egui copied to clipboard

eFrame: Native system menubar

Open nu11ptr opened this issue 2 years ago • 11 comments

Is your feature request related to a problem? Please describe. Although users now generally accept non-native UIs, some system integration is often expected or appreciated. I believe system menu bar is one of these things, esp. on macOs

Describe the solution you'd like Native system menu bar integration on the major three platforms: Windows, Mac and Linux. Linux could possibly emulated (or omitted and provided by egui) to avoid a Gtk or Qt dependency, which may not be desired.

Describe alternatives you've considered There is an FLTK egui frame project, but it would be nice to have a native option right in eframe

Additional context The primary driver is macOs since the system menu bar is on the top of the screen. If just that were added, that might be sufficient, but it would be ideal to gain this on windows/linux as well

nu11ptr avatar Sep 28 '23 15:09 nu11ptr

This is an absolute must for me to consider using eframe on macOS. I started messing about to see if I could make any progress (bear in mind I've never touched this project before and I've only barely worked with the objc2 project before). This is what I've got so far:

Screenshot 2024-10-06 at 12 34 05 AM

It's really not much, but it's something. Here's the code that produced this:

diff --git a/crates/eframe/src/native/app_icon.rs b/crates/eframe/src/native/app_icon.rs
index 840bf367..3569f92a 100644
--- a/crates/eframe/src/native/app_icon.rs
+++ b/crates/eframe/src/native/app_icon.rs
@@ -203,9 +203,12 @@ fn set_title_and_icon_mac(title: &str, icon_data: Option<&IconData>) -> AppIconS
     use crate::icon_data::IconDataExt as _;
     crate::profile_function!();
 
-    use objc2::ClassType;
-    use objc2_app_kit::{NSApplication, NSImage};
-    use objc2_foundation::{NSData, NSString};
+    use log::debug;
+    use objc2::{
+        declare_class, msg_send_id, mutability, rc::Retained, sel, ClassType, DeclaredClass,
+    };
+    use objc2_app_kit::{NSApplication, NSImage, NSMenu, NSMenuItem};
+    use objc2_foundation::{ns_string, MainThreadMarker, NSData, NSObject, NSString};
 
     let png_bytes = if let Some(icon_data) = icon_data {
         match icon_data.to_png_bytes() {
@@ -250,6 +253,58 @@ fn set_title_and_icon_mac(title: &str, icon_data: Option<&IconData>) -> AppIconS
                     app_menu.setTitle(&NSString::from_str(title));
                 }
             }
+
+            let test_menu_item = main_menu.addItemWithTitle_action_keyEquivalent(
+                ns_string!(""),
+                None,
+                ns_string!(""),
+            );
+            let mtm = MainThreadMarker::new().expect("must be on the main thread");
+            let test_menu = NSMenu::initWithTitle(mtm.alloc::<NSMenu>(), ns_string!("Test Menu"));
+
+            declare_class! {
+                struct TestMenuObject;
+
+                unsafe impl ClassType for TestMenuObject {
+                    type Super = NSObject;
+                    type Mutability = mutability::InteriorMutable;
+                    const NAME: &'static str = "TestMenuObject";
+                }
+
+                impl DeclaredClass for TestMenuObject {}
+
+                unsafe impl TestMenuObject {
+                    #[method(testButton1)]
+                    fn __test_button_1() {
+                        debug!("Test Button 1 clicked");
+                    }
+
+                    #[method(testButton2)]
+                    fn __test_button_2() {
+                        debug!("Test Button 2 clicked");
+                    }
+                }
+            }
+
+            let test_menu_object: Retained<TestMenuObject> =
+                msg_send_id![TestMenuObject::alloc(), init];
+            let test_menu_button_1 = test_menu.addItemWithTitle_action_keyEquivalent(
+                ns_string!("Test Button 1"),
+                Some(sel!(testButton1)),
+                ns_string!("1"),
+            );
+
+            test_menu_button_1.setTarget(Some(&test_menu_object));
+            test_menu.addItem(&NSMenuItem::separatorItem(mtm));
+
+            let test_menu_button_2 = test_menu.addItemWithTitle_action_keyEquivalent(
+                ns_string!("Test Button 2"),
+                Some(sel!(testButton2)),
+                ns_string!("2"),
+            );
+
+            test_menu_button_2.setTarget(Some(&test_menu_object));
+            test_menu_item.setSubmenu(Some(&test_menu));
         }
 
         // The title in the Dock apparently can't be changed.

Unfortunately, you can't click these test menu buttons, I haven't managed to figure out how to get that to work, I'm tired and I want to go to bed now.

I don't really plan to be the one to actually implement the functionality described in this issue as I feel I lack the expertise necessary, but I want to draw attention to this issue, I want to bump it a little bit, I really want this to happen.

valentinegb avatar Oct 06 '24 07:10 valentinegb

I have attained... partial functionality!

Screenshot 2024-10-06 at 12 53 26 PM

I think I understand what I need to do to have custom actions on menu items, but I'm not sure how exactly to do it. I think I need to add a method to the class conforming to NSApplicationDelegate, but it looks like to me that eframe doesn't really handle the specific creation of such a class itself. That seems to be delegated (pun unintended) to, I would assume, winit.

valentinegb avatar Oct 06 '24 20:10 valentinegb

Any reason why this functionality would need to live in egui or eframe?

frewsxcv avatar Nov 11 '24 14:11 frewsxcv

Any reason why this functionality would need to live in egui or eframe?

I think since the NSApp is created by egui, all AppDelegates need to be handled there and cannot be handled outside. That is my understanding, but I might be wrong.

I think this is handled in the function where the Icon is set: https://github.com/emilk/egui/blob/cf965aaa30987a5b6fa2380f37c0ce8cb869347d/crates/eframe/src/native/app_icon.rs#L202

hacknus avatar Jan 20 '25 09:01 hacknus

Any reason why this functionality would need to live in egui or eframe?

Is there any other alternative? to achieving this? this is a big deal for me to finally use egui

rubbieKelvin avatar Aug 04 '25 11:08 rubbieKelvin

@valentinegb how did you implement this?

rubbieKelvin avatar Aug 04 '25 13:08 rubbieKelvin

@valentinegb how did you implement this?

I don't really remember, sorry, it's been too long for me ^^'

valentinegb avatar Aug 04 '25 16:08 valentinegb

as far as I know, as soon as egui upgrades winit to 0.31, NSApp main thread handler will be available and the user can do everything without having to interfere with egui. See here: https://github.com/emilk/egui/issues/5620 and the winit discussion: https://github.com/rust-windowing/winit/issues/4260

hacknus avatar Aug 04 '25 19:08 hacknus

Here how I got it working with all the options in macos

//main.rs

#[derive()]
struct App {
    // The object needs to be alive for the lifetime of the app
    #[cfg(target_os = "macos")]
    menu_handler: Option<objc2::rc::Retained<AppMenuHandler>>,
}

impl App {
    pub fn new() -> Self {
        Self {
            #[cfg(target_os = "macos")]
            menu_handler: None,
        }
    }
}

pub async fn run() -> std::result::Result<(), eframe::Error> {
    let options = eframe::NativeOptions {
        viewport: eframe::egui::ViewportBuilder::default()
            .with_resizable(true)
            .with_active(true)
            .with_fullsize_content_view(false)
            .with_icon(IconData::default())
            .with_inner_size([1200.0, 650.0])
            .with_min_inner_size([800.0, 600.0])
            .with_title_shown(true)
            .with_mouse_passthrough(false),

        centered: true,

        ..Default::default()
    };

    #[cfg(target_os = "macos")]
    let handle = make_handler().unwrap();

    eframe::run_native(
        "App",
        options,
        Box::new(|_cc| {
            #[cfg(target_os = "macos")]
            create_global_menu(handle);

            let mut app = Box::new(App::new());

            #[cfg(target_os = "macos")]
            {
                app.menu_handler = handle;
            }

            Ok(app)
        }),
    )
}


//create_menu.rs

use objc2::rc::Retained;
use objc2::runtime::AnyObject;

use objc2::{ClassType, define_class, msg_send, sel};
use objc2_app_kit::NSApp;
use objc2_app_kit::{NSMenu, NSMenuItem};
use objc2_foundation::{MainThreadMarker, NSObject, NSString};

#[expect(unsafe_code)]
pub fn create_global_menu(handler: Retained<AppMenuHandler>) {
    unsafe {
        let mtm = MainThreadMarker::new().expect("must be on main thread");

        let app = NSApp(mtm);

        // Change the title in the top bar - for python processes this would be again "python" otherwise.
        if let Some(main_menu) = app.mainMenu()
            && let Some(item) = main_menu.itemAtIndex(0)
            && let Some(app_menu) = item.submenu()
        {
            //File menu
            let file_menu_item = NSMenuItem::new(mtm);
            main_menu.addItem(&file_menu_item);

            let file_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("File"));

            let open_item = NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Open…"),
                Some(sel!(openDocument:)),
                &NSString::from_str("o"),
            );
            open_item.setTarget(Some(handler.as_ref() as &objc2::runtime::AnyObject));

            let save_item = NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Save"),
                Some(sel!(saveDocument:)),
                &NSString::from_str("s"),
            );

            save_item.setTarget(Some(handler.as_ref() as &objc2::runtime::AnyObject)); // Add this line!

            let close_item = NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Close"),
                Some(sel!(performClose:)),
                &NSString::from_str("w"),
            );
            file_menu.addItem(&save_item);
            file_menu.addItem(&open_item);
            file_menu.addItem(&NSMenuItem::separatorItem(mtm));
            file_menu.addItem(&close_item);
            file_menu_item.setSubmenu(Some(&file_menu));

            //Edit menu
            let edit_menu_item = NSMenuItem::new(mtm);
            main_menu.addItem(&edit_menu_item);

            let edit_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("Edit"));
            edit_menu.addItem(&NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Undo"),
                Some(sel!(undo:)),
                &NSString::from_str("z"),
            ));
            edit_menu.addItem(&NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Redo"),
                Some(sel!(redo:)),
                &NSString::from_str("Z"),
            ));
            edit_menu.addItem(&NSMenuItem::separatorItem(mtm));
            edit_menu.addItem(&NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Cut"),
                Some(sel!(cut:)),
                &NSString::from_str("x"),
            ));
            edit_menu.addItem(&NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Copy"),
                Some(sel!(copy:)),
                &NSString::from_str("c"),
            ));
            edit_menu.addItem(&NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Paste"),
                Some(sel!(paste:)),
                &NSString::from_str("v"),
            ));
            edit_menu.addItem(&NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Select All"),
                Some(sel!(selectAll:)),
                &NSString::from_str("a"),
            ));

            edit_menu.addItem(&NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Select All"),
                Some(sel!(selectAll:)),
                &NSString::from_str("d"),
            ));
            edit_menu_item.setSubmenu(Some(&edit_menu));

            //View menu
            let view_menu_item = NSMenuItem::new(mtm);
            main_menu.addItem(&view_menu_item);

            let view_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("View"));
            view_menu.addItem(&NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Enter Full Screen"),
                Some(sel!(toggleFullScreen:)),
                &NSString::from_str("f"),
            ));

            view_menu_item.setSubmenu(Some(&view_menu));

            //Window menu
            let window_menu_item = NSMenuItem::new(mtm);
            main_menu.addItem(&window_menu_item);

            let window_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("Window"));
            window_menu.addItem(&NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Minimize"),
                Some(sel!(performMiniaturize:)),
                &NSString::from_str("m"),
            ));
            window_menu.addItem(&NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Zoom"),
                Some(sel!(performZoom:)),
                &NSString::new(),
            ));
            window_menu_item.setSubmenu(Some(&window_menu));

            //Help menu
            let help_menu_item = NSMenuItem::new(mtm);
            main_menu.addItem(&help_menu_item);

            let help_menu = NSMenu::initWithTitle(mtm.alloc(), &NSString::from_str("Help"));
            let help_item = NSMenuItem::initWithTitle_action_keyEquivalent(
                mtm.alloc(),
                &NSString::from_str("Help"),
                Some(sel!(showHelp:)),
                &NSString::from_str("?"),
            );
            help_menu.addItem(&help_item);
            help_menu_item.setSubmenu(Some(&help_menu));
        }
    }
}


//This class is used to override the existing methods for the menu items
define_class!(
    #[unsafe(super(NSObject))]
    #[name = "AppMenuHandler"]
    pub struct AppMenuHandler;

     impl AppMenuHandler {


         #[unsafe(method(init))]
         fn init(&self) -> *mut Self {
                   unsafe { objc2::msg_send![super(self), init] }
               }

        #[unsafe(method(openDocument:))]
        fn open_document(&self, _sender: &AnyObject) {
            println!("Open menu item clicked!");
        }

        #[unsafe(method(saveDocument:))]
        fn save_document(&self, _sender:  &AnyObject) {
            println!("Save menu item clicked!");
        }

        #[unsafe(method(validateMenuItem:))]
               fn validate_menu_item(&self, _item: &NSMenuItem) -> bool {
                   true
               }
    }
);

pub fn make_handler() -> Option<Retained<AppMenuHandler>> {
    unsafe {
        let obj: *mut AppMenuHandler = msg_send![AppMenuHandler::class(), alloc];
        Retained::from_raw(msg_send![obj, init])
    }
}

razein97 avatar Oct 13 '25 17:10 razein97

This is an example using muda.

use egui::IconData;
use muda::Menu;
use muda::{
    MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
    accelerator::{Accelerator, Code, Modifiers},
};

mod modules;
mod state;

#[derive()]
struct App {
    menu: Option<Menu>,
    menu_rx: Option<std::sync::mpsc::Receiver<MenuEvent>>,
}

impl App {
    pub fn new() -> Self {
        Self {
            menu: None,
            menu_rx: None,
        }
    }

    fn handle_menu_event(&mut self, event: MenuEvent) {
        // Get the menu item ID
        let id = event.id;

        // You'll need to store menu item IDs to identify which was clicked
        // For now, you can print to see what's being clicked
        println!("Menu event received: {id:?}");

        // Handle specific menu items
        // Example:
        // if id == self.save_item_id {
        //     // Handle save
        // }
    }
}

impl eframe::App for App {
    fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
        // Poll menu events
        // Take the receiver temporarily
        if let Some(rx) = self.menu_rx.take() {
            while let Ok(event) = rx.try_recv() {
                self.handle_menu_event(event);
            }
            // Put it back
            self.menu_rx = Some(rx);
        }

        egui::CentralPanel::default().show(ctx, |ui| {
            ui.centered_and_justified(|ui| {
                ui.label("Muda Test.");
            });
        });
    }
}

pub async fn run() -> std::result::Result<(), eframe::Error> {
    let options = eframe::NativeOptions {
        viewport: eframe::egui::ViewportBuilder::default()
            .with_resizable(true)
            .with_active(true)
            .with_fullsize_content_view(false)
            .with_icon(IconData::default())
            .with_inner_size([1200.0, 650.0])
            .with_min_inner_size([800.0, 600.0])
            .with_title_shown(true)
            .with_mouse_passthrough(false),

        centered: true,

        ..Default::default()
    };

    #[cfg(target_os = "macos")]
    eframe::run_native(
        "App",
        options,
        Box::new(move |_cc| {
            let mut app = Box::new(App::new());

            if app.menu.is_none() {
                let m = create_menu();

                m.0.init_for_nsapp(); // <-- now safe: NSApp is ready
                app.menu = Some(m.0);
                app.menu_rx = Some(m.1);
            }

            Ok(app)
        }),
    )
}

#[cfg(target_os = "macos")]
fn create_menu() -> (Menu, std::sync::mpsc::Receiver<MenuEvent>) {
    use muda::MenuEvent;

    let menu = Menu::new();

    // Set up menu event channel
    let (tx, rx) = std::sync::mpsc::channel();
    MenuEvent::set_event_handler(Some(move |event| {
        let _ = tx.send(event);
    }));

    // App menu (first menu with app name)
    let app_menu = Submenu::new("App", true);
    app_menu
        .append(&PredefinedMenuItem::about(None, None))
        .unwrap();
    app_menu.append(&PredefinedMenuItem::separator()).unwrap();
    app_menu
        .append(&PredefinedMenuItem::services(None))
        .unwrap();
    app_menu.append(&PredefinedMenuItem::separator()).unwrap();
    app_menu.append(&PredefinedMenuItem::hide(None)).unwrap();
    app_menu
        .append(&PredefinedMenuItem::hide_others(None))
        .unwrap();
    app_menu
        .append(&PredefinedMenuItem::show_all(None))
        .unwrap();
    app_menu.append(&PredefinedMenuItem::separator()).unwrap();
    app_menu.append(&PredefinedMenuItem::quit(None)).unwrap();
    menu.append(&app_menu).unwrap();

    // File menu
    let file_menu = Submenu::new("File", true);
    let new_item = MenuItem::new("New", true, None);
    let open_item = MenuItem::new("Open", true, None);
    let save_item = MenuItem::new(
        "Save",
        true,
        Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyS)),
    );
    file_menu.append(&new_item).unwrap();
    file_menu.append(&open_item).unwrap();
    file_menu.append(&save_item).unwrap();
    file_menu.append(&PredefinedMenuItem::separator()).unwrap();
    file_menu
        .append(&PredefinedMenuItem::close_window(None))
        .unwrap();
    menu.append(&file_menu).unwrap();

    // Edit menu
    let edit_menu = Submenu::new("Edit", true);
    edit_menu.append(&PredefinedMenuItem::undo(None)).unwrap();
    edit_menu.append(&PredefinedMenuItem::redo(None)).unwrap();
    edit_menu.append(&PredefinedMenuItem::separator()).unwrap();
    edit_menu.append(&PredefinedMenuItem::cut(None)).unwrap();
    edit_menu.append(&PredefinedMenuItem::copy(None)).unwrap();
    edit_menu.append(&PredefinedMenuItem::paste(None)).unwrap();
    edit_menu
        .append(&PredefinedMenuItem::select_all(None))
        .unwrap();
    menu.append(&edit_menu).unwrap();

    // Window menu
    let window_menu = Submenu::new("Window", true);
    window_menu
        .append(&PredefinedMenuItem::minimize(None))
        .unwrap();
    window_menu
        .append(&PredefinedMenuItem::maximize(None))
        .unwrap();
    window_menu
        .append(&PredefinedMenuItem::separator())
        .unwrap();
    window_menu
        .append(&PredefinedMenuItem::fullscreen(None))
        .unwrap();
    menu.append(&window_menu).unwrap();

    (menu, rx)
}

razein97 avatar Oct 14 '25 05:10 razein97

@razein97 I really appreciate the examples, they've been very helpful.

I have noticed that one will want to call egui::Context::request_repaint() (passing in cc.egui_ctx.clone() to the menu creation function from eframe::run_native's callback works well) after sending an event to the window from the menu (ie: right after the tx.send() in the second example). Otherwise it can be some time before the window is updated. It's noticable on macos when using the menus with the cursor and avoiding moving the cursor after clicking the menu entry.

codyps avatar Oct 16 '25 02:10 codyps