cocoanut 0.2.3

A minimal, declarative macOS GUI framework for Rust
use crate::error::Result;

/// A menu bar item
#[derive(Debug, Clone)]
#[must_use = "Menu must be installed with install_menus()"]
pub struct Menu {
    pub title: String,
    pub items: Vec<MenuItem>,
}

/// A single menu item
#[derive(Debug, Clone)]
#[must_use = "MenuItem must be added to a Menu"]
pub struct MenuItem {
    pub title: String,
    pub shortcut: Option<String>,
    pub separator: bool,
    pub callback_id: Option<usize>,
}

impl Menu {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            items: Vec::new(),
        }
    }

    pub fn item(mut self, item: MenuItem) -> Self {
        self.items.push(item);
        self
    }

    pub fn separator(mut self) -> Self {
        self.items.push(MenuItem::separator());
        self
    }
}

impl MenuItem {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            shortcut: None,
            separator: false,
            callback_id: None,
        }
    }

    pub fn shortcut(mut self, key: impl Into<String>) -> Self {
        self.shortcut = Some(key.into());
        self
    }

    pub fn on_action(mut self, callback_id: usize) -> Self {
        self.callback_id = Some(callback_id);
        self
    }

    pub fn separator() -> Self {
        Self {
            title: String::new(),
            shortcut: None,
            separator: true,
            callback_id: None,
        }
    }
}

/// Install menus into the running NSApplication
#[cfg(not(test))]
pub fn install_menus(menus: &[Menu]) -> Result<()> {
    use objc::runtime::{Class, Object};
    use objc::{msg_send, sel, sel_impl};

    unsafe {
        let ns_app: *mut Object = msg_send![
            Class::get("NSApplication").ok_or("NSApplication not found")?,
            sharedApplication
        ];
        let menu_bar: *mut Object = msg_send![Class::get("NSMenu").ok_or("NSMenu not found")?, new];

        for menu in menus {
            let title_cstr = std::ffi::CString::new(menu.title.as_str())?;
            let title_ns: *mut Object =
                msg_send![objc::class!(NSString), stringWithUTF8String: title_cstr.as_ptr()];

            let sub_menu: *mut Object = msg_send![objc::class!(NSMenu), alloc];
            let sub_menu: *mut Object = msg_send![sub_menu, initWithTitle: title_ns];

            for item in &menu.items {
                if item.separator {
                    let sep: *mut Object = msg_send![objc::class!(NSMenuItem), separatorItem];
                    let _: () = msg_send![sub_menu, addItem: sep];
                } else {
                    let item_title = std::ffi::CString::new(item.title.as_str())?;
                    let item_ns: *mut Object = msg_send![objc::class!(NSString), stringWithUTF8String: item_title.as_ptr()];
                    let key = item.shortcut.as_deref().unwrap_or("");
                    let key_cstr = std::ffi::CString::new(key)?;
                    let key_ns: *mut Object =
                        msg_send![objc::class!(NSString), stringWithUTF8String: key_cstr.as_ptr()];

                    let mi: *mut Object = msg_send![objc::class!(NSMenuItem), alloc];
                    if let Some(cb_id) = item.callback_id {
                        let handler = crate::event::action_handler();
                        let action_sel = objc::sel!(handleAction:);
                        let mi: *mut Object = msg_send![mi, initWithTitle: item_ns action: action_sel keyEquivalent: key_ns];
                        let _: () = msg_send![mi, setTarget: handler];
                        let _: () = msg_send![mi, setTag: cb_id as isize];
                        crate::event::map_tag(cb_id as isize, cb_id);
                        let _: () = msg_send![sub_menu, addItem: mi];
                    } else {
                        let mi: *mut Object = msg_send![mi, initWithTitle: item_ns action: std::ptr::null::<objc::runtime::Sel>() keyEquivalent: key_ns];
                        let _: () = msg_send![sub_menu, addItem: mi];
                    }
                }
            }

            let bar_item: *mut Object = msg_send![objc::class!(NSMenuItem), new];
            let _: () = msg_send![bar_item, setSubmenu: sub_menu];
            let _: () = msg_send![menu_bar, addItem: bar_item];
        }

        let _: () = msg_send![ns_app, setMainMenu: menu_bar];
    }
    Ok(())
}

#[cfg(test)]
pub fn install_menus(_menus: &[Menu]) -> Result<()> {
    Ok(())
}

/// Create a default app menu (App name, Edit, Window, Help)
pub fn default_menus(app_name: &str) -> Vec<Menu> {
    vec![
        Menu::new(app_name)
            .item(MenuItem::new(format!("About {}", app_name)))
            .separator()
            .item(MenuItem::new(format!("Quit {}", app_name)).shortcut("q")),
        Menu::new("Edit")
            .item(MenuItem::new("Undo").shortcut("z"))
            .item(MenuItem::new("Redo").shortcut("Z"))
            .separator()
            .item(MenuItem::new("Cut").shortcut("x"))
            .item(MenuItem::new("Copy").shortcut("c"))
            .item(MenuItem::new("Paste").shortcut("v"))
            .item(MenuItem::new("Select All").shortcut("a")),
        Menu::new("Window")
            .item(MenuItem::new("Minimize").shortcut("m"))
            .item(MenuItem::new("Zoom")),
        Menu::new("Help").item(MenuItem::new(format!("{} Help", app_name))),
    ]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_menu_creation() {
        let m = Menu::new("File")
            .item(MenuItem::new("Open").shortcut("o"))
            .separator()
            .item(MenuItem::new("Quit").shortcut("q"));
        assert_eq!(m.title, "File");
        assert_eq!(m.items.len(), 3);
        assert!(!m.items[0].separator);
        assert!(m.items[1].separator);
        assert_eq!(m.items[2].shortcut.as_deref(), Some("q"));
    }

    #[test]
    fn test_default_menus() {
        let menus = default_menus("TestApp");
        assert_eq!(menus.len(), 4);
        assert_eq!(menus[0].title, "TestApp");
        assert_eq!(menus[1].title, "Edit");
    }

    #[test]
    fn menu_item_on_action() {
        let mi = MenuItem::new("Save").shortcut("s").on_action(404);
        assert_eq!(mi.callback_id, Some(404));
    }

    #[test]
    fn install_menus_mock_succeeds() {
        let menus = vec![Menu::new("File").item(MenuItem::new("Close"))];
        assert!(install_menus(&menus).is_ok());
    }
}