Skip to main content

platform_glyph/
menu.rs

1//! Native OS menu bar via `muda`.
2//!
3//! Build a `MenuBar` with the declarative API, then pass it to `AppBuilder::menu()`.
4//! On macOS it becomes the global app menu. On Windows it attaches to each window.
5//!
6//! See the `glyph` crate for a full usage example.
7
8use muda::{
9    accelerator::{Accelerator, Code, Modifiers},
10    Menu, MenuItem, PredefinedMenuItem, Submenu,
11};
12use std::collections::HashMap;
13use std::sync::{Arc, Mutex};
14
15type HandlerMap = Arc<Mutex<HashMap<String, Box<dyn Fn() + Send + Sync>>>>;
16
17/// A declarative native menu bar definition. Pass to `AppBuilder::menu()`.
18pub struct MenuBar {
19    pub(crate) inner: Menu,
20    pub(crate) handlers: HandlerMap,
21}
22
23impl MenuBar {
24    pub fn new() -> Self {
25        Self {
26            inner: Menu::new(),
27            handlers: Arc::new(Mutex::new(HashMap::new())),
28        }
29    }
30
31    /// Add a top-level submenu. The closure receives a `SubMenuBuilder` and must
32    /// return it (call `.build()` or just return it — `Into<SubMenuBuilt>` is implemented).
33    pub fn submenu(self, label: &str, build: impl FnOnce(SubMenuBuilder) -> SubMenuBuilder) -> Self {
34        let handlers = Arc::clone(&self.handlers);
35        let builder = SubMenuBuilder::new(label, handlers);
36        let built = build(builder).finish();
37        let _ = self.inner.append(&built.submenu);
38        Self { inner: self.inner, handlers: built.handlers }
39    }
40}
41
42impl Default for MenuBar {
43    fn default() -> Self { Self::new() }
44}
45
46
47pub struct SubMenuBuilder {
48    label: String,
49    items: Vec<muda::MenuItemKind>,
50    handlers: HandlerMap,
51}
52
53pub(crate) struct SubMenuBuilt {
54    pub submenu: Submenu,
55    pub handlers: HandlerMap,
56}
57
58impl SubMenuBuilder {
59    fn new(label: &str, handlers: HandlerMap) -> Self {
60        Self { label: label.to_string(), items: vec![], handlers }
61    }
62
63    /// Add a regular menu item with an optional keyboard shortcut.
64    /// Shortcut format: `"CmdOrCtrl+S"`, `"Alt+F4"`, `""` for none.
65    pub fn item(mut self, label: &str, shortcut: &str, on_click: impl Fn() + Send + Sync + 'static) -> Self {
66        let accel = parse_shortcut(shortcut);
67        let item = MenuItem::new(label, true, accel);
68        let id = item.id().0.to_string();
69        self.handlers.lock().unwrap().insert(id, Box::new(on_click));
70        self.items.push(muda::MenuItemKind::MenuItem(item));
71        self
72    }
73
74    /// Add a visual separator line.
75    pub fn separator(mut self) -> Self {
76        self.items.push(muda::MenuItemKind::Predefined(PredefinedMenuItem::separator()));
77        self
78    }
79
80    // ── Predefined (OS-native) items ───────────────────────────────────────
81    pub fn cut(mut self) -> Self        { self.items.push(muda::MenuItemKind::Predefined(PredefinedMenuItem::cut(None))); self }
82    pub fn copy(mut self) -> Self       { self.items.push(muda::MenuItemKind::Predefined(PredefinedMenuItem::copy(None))); self }
83    pub fn paste(mut self) -> Self      { self.items.push(muda::MenuItemKind::Predefined(PredefinedMenuItem::paste(None))); self }
84    pub fn select_all(mut self) -> Self { self.items.push(muda::MenuItemKind::Predefined(PredefinedMenuItem::select_all(None))); self }
85    pub fn undo(mut self) -> Self       { self.items.push(muda::MenuItemKind::Predefined(PredefinedMenuItem::undo(None))); self }
86    pub fn redo(mut self) -> Self       { self.items.push(muda::MenuItemKind::Predefined(PredefinedMenuItem::redo(None))); self }
87    pub fn minimize(mut self) -> Self   { self.items.push(muda::MenuItemKind::Predefined(PredefinedMenuItem::minimize(None))); self }
88    pub fn maximize(mut self) -> Self   { self.items.push(muda::MenuItemKind::Predefined(PredefinedMenuItem::maximize(None))); self }
89    pub fn fullscreen(mut self) -> Self { self.items.push(muda::MenuItemKind::Predefined(PredefinedMenuItem::fullscreen(None))); self }
90    pub fn close_window(mut self) -> Self { self.items.push(muda::MenuItemKind::Predefined(PredefinedMenuItem::close_window(None))); self }
91    pub fn quit(mut self) -> Self       { self.items.push(muda::MenuItemKind::Predefined(PredefinedMenuItem::quit(None))); self }
92
93    pub fn about(mut self, app_name: &str) -> Self {
94        self.items.push(muda::MenuItemKind::Predefined(PredefinedMenuItem::about(
95            None,
96            Some(muda::AboutMetadata { name: Some(app_name.to_string()), ..Default::default() }),
97        )));
98        self
99    }
100
101    /// Nest another submenu.
102    pub fn submenu(mut self, label: &str, build: impl FnOnce(SubMenuBuilder) -> SubMenuBuilder) -> Self {
103        let handlers = Arc::clone(&self.handlers);
104        let builder = SubMenuBuilder::new(label, handlers);
105        let built = build(builder).finish();
106        self.items.push(muda::MenuItemKind::Submenu(built.submenu));
107        self.handlers = built.handlers;
108        self
109    }
110
111    pub(crate) fn finish(self) -> SubMenuBuilt {
112        let refs: Vec<&dyn muda::IsMenuItem> = self.items.iter().map(|k| -> &dyn muda::IsMenuItem {
113            match k {
114                muda::MenuItemKind::MenuItem(i) => i,
115                muda::MenuItemKind::Submenu(s) => s,
116                muda::MenuItemKind::Predefined(p) => p,
117                muda::MenuItemKind::Check(c) => c,
118                muda::MenuItemKind::Icon(i) => i,
119            }
120        }).collect();
121        let submenu = Submenu::with_items(&self.label, true, &refs).expect("submenu");
122        SubMenuBuilt { submenu, handlers: self.handlers }
123    }
124}
125
126
127fn parse_shortcut(s: &str) -> Option<Accelerator> {
128    if s.is_empty() { return None; }
129    let mut mods = Modifiers::empty();
130    let mut key_part = "";
131    for part in s.split('+') {
132        match part.trim() {
133            "CmdOrCtrl" | "Cmd" => {
134                #[cfg(target_os = "macos")]
135                { mods |= Modifiers::SUPER; }
136                #[cfg(not(target_os = "macos"))]
137                { mods |= Modifiers::CONTROL; }
138            }
139            "Ctrl"             => { mods |= Modifiers::CONTROL; }
140            "Alt" | "Option"   => { mods |= Modifiers::ALT; }
141            "Shift"            => { mods |= Modifiers::SHIFT; }
142            "Meta" | "Super"   => { mods |= Modifiers::SUPER; }
143            other              => { key_part = other; }
144        }
145    }
146    let code = parse_code(key_part)?;
147    Some(Accelerator::new(Some(mods), code))
148}
149
150fn parse_code(s: &str) -> Option<Code> {
151    if s.len() == 1 {
152        return match s.chars().next().unwrap().to_ascii_uppercase() {
153            'A' => Some(Code::KeyA), 'B' => Some(Code::KeyB), 'C' => Some(Code::KeyC),
154            'D' => Some(Code::KeyD), 'E' => Some(Code::KeyE), 'F' => Some(Code::KeyF),
155            'G' => Some(Code::KeyG), 'H' => Some(Code::KeyH), 'I' => Some(Code::KeyI),
156            'J' => Some(Code::KeyJ), 'K' => Some(Code::KeyK), 'L' => Some(Code::KeyL),
157            'M' => Some(Code::KeyM), 'N' => Some(Code::KeyN), 'O' => Some(Code::KeyO),
158            'P' => Some(Code::KeyP), 'Q' => Some(Code::KeyQ), 'R' => Some(Code::KeyR),
159            'S' => Some(Code::KeyS), 'T' => Some(Code::KeyT), 'U' => Some(Code::KeyU),
160            'V' => Some(Code::KeyV), 'W' => Some(Code::KeyW), 'X' => Some(Code::KeyX),
161            'Y' => Some(Code::KeyY), 'Z' => Some(Code::KeyZ),
162            '0' => Some(Code::Digit0), '1' => Some(Code::Digit1), '2' => Some(Code::Digit2),
163            '3' => Some(Code::Digit3), '4' => Some(Code::Digit4), '5' => Some(Code::Digit5),
164            '6' => Some(Code::Digit6), '7' => Some(Code::Digit7), '8' => Some(Code::Digit8),
165            '9' => Some(Code::Digit9),
166            _ => None,
167        };
168    }
169    match s {
170        "F1"  => Some(Code::F1),  "F2"  => Some(Code::F2),  "F3"  => Some(Code::F3),
171        "F4"  => Some(Code::F4),  "F5"  => Some(Code::F5),  "F6"  => Some(Code::F6),
172        "F7"  => Some(Code::F7),  "F8"  => Some(Code::F8),  "F9"  => Some(Code::F9),
173        "F10" => Some(Code::F10), "F11" => Some(Code::F11), "F12" => Some(Code::F12),
174        "Enter"|"Return"  => Some(Code::Enter),
175        "Escape"|"Esc"    => Some(Code::Escape),
176        "Backspace"       => Some(Code::Backspace),
177        "Delete"          => Some(Code::Delete),
178        "Tab"             => Some(Code::Tab),
179        "Space"           => Some(Code::Space),
180        "ArrowUp"|"Up"    => Some(Code::ArrowUp),
181        "ArrowDown"|"Down"=> Some(Code::ArrowDown),
182        "ArrowLeft"|"Left"=> Some(Code::ArrowLeft),
183        "ArrowRight"|"Right"=>Some(Code::ArrowRight),
184        "Home"      => Some(Code::Home),
185        "End"       => Some(Code::End),
186        "PageUp"    => Some(Code::PageUp),
187        "PageDown"  => Some(Code::PageDown),
188        _ => None,
189    }
190}
191
192
193#[cfg(target_os = "macos")]
194pub fn install_menu_macos(menu: &MenuBar) {
195    menu.inner.init_for_nsapp();
196}
197#[cfg(not(target_os = "macos"))]
198pub fn install_menu_macos(_menu: &MenuBar) {}
199
200#[cfg(target_os = "windows")]
201pub fn install_menu_windows(menu: &MenuBar, hwnd: isize) {
202    unsafe { let _ = menu.inner.init_for_hwnd(hwnd); }
203}
204#[cfg(not(target_os = "windows"))]
205pub fn install_menu_windows(_menu: &MenuBar, _hwnd: isize) {}
206
207/// Drain pending menu events and fire registered callbacks.
208pub fn poll_menu_events(handlers: &HandlerMap) {
209    while let Ok(event) = muda::MenuEvent::receiver().try_recv() {
210        let id = event.id().0.to_string();
211        if let Some(f) = handlers.lock().unwrap().get(&id) {
212            f();
213        }
214    }
215}