Skip to main content

rightkit_tauri_shell/
menu.rs

1//! Native application menu: standard App / Edit / View / Window / Help structure, no browser
2//! verbs (no Reload, no zoom, no Back), plus app-defined groups.
3//!
4//! Donors: ViewRight's macOS menu (Hide, Hide Others, Quit, Edit, Window), CodeRight's
5//! `build_application_menu` (typed ids forwarded to the renderer exactly once) and HeardRight's
6//! menu/tray gating. Custom items carry stable string ids; a click is delivered as the
7//! `rightkit-shell://menu` event and to an optional Rust callback.
8
9use std::collections::HashMap;
10
11use serde::{Deserialize, Serialize};
12use tauri::menu::{Menu, MenuItem, MenuItemKind, PredefinedMenuItem, Submenu, SubmenuBuilder};
13use tauri::{AppHandle, Wry};
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "kind", rename_all = "kebab-case")]
17pub enum MenuEntry {
18    Item {
19        id: String,
20        label: String,
21        #[serde(default)]
22        accelerator: Option<String>,
23        #[serde(default = "yes")]
24        enabled: bool,
25    },
26    Separator,
27}
28
29/// Id of the app-menu quit item (macOS "Quit <app>", Windows/Linux File > "Exit <app>"). A click
30/// is handled by the shell as `lifecycle::request_quit` and is not emitted as a menu event.
31pub const ID_QUIT: &str = "rk.menu.quit";
32
33fn yes() -> bool {
34    true
35}
36
37impl MenuEntry {
38    pub fn item(id: &str, label: &str) -> Self {
39        MenuEntry::Item {
40            id: id.into(),
41            label: label.into(),
42            accelerator: None,
43            enabled: true,
44        }
45    }
46    pub fn accel(id: &str, label: &str, accelerator: &str) -> Self {
47        MenuEntry::Item {
48            id: id.into(),
49            label: label.into(),
50            accelerator: Some(accelerator.into()),
51            enabled: true,
52        }
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct MenuGroup {
58    pub label: String,
59    pub entries: Vec<MenuEntry>,
60}
61
62#[derive(Debug, Clone, Default)]
63pub struct MenuSpec {
64    pub app_name: String,
65    /// Extra top-level menus, placed after Edit/View.
66    pub groups: Vec<MenuGroup>,
67    /// Entries appended to Help.
68    pub help: Vec<MenuEntry>,
69    /// `Some((id, accelerator))` adds "Settings…" to the app menu (macOS) / File menu.
70    pub settings: Option<(String, String)>,
71}
72
73pub struct BuiltMenu {
74    pub menu: Menu<Wry>,
75    pub items: HashMap<String, MenuItem<Wry>>,
76}
77
78fn add_entries(
79    app: &AppHandle<Wry>,
80    sub: &Submenu<Wry>,
81    entries: &[MenuEntry],
82    items: &mut HashMap<String, MenuItem<Wry>>,
83) -> Result<(), String> {
84    for e in entries {
85        match e {
86            MenuEntry::Separator => sub
87                .append(&PredefinedMenuItem::separator(app).map_err(s)?)
88                .map_err(s)?,
89            MenuEntry::Item {
90                id,
91                label,
92                accelerator,
93                enabled,
94            } => {
95                let mi = MenuItem::with_id(app, id, label, *enabled, accelerator.as_deref())
96                    .map_err(s)?;
97                sub.append(&mi).map_err(s)?;
98                items.insert(id.clone(), mi);
99            }
100        }
101    }
102    Ok(())
103}
104
105fn s<E: std::fmt::Display>(e: E) -> String {
106    e.to_string()
107}
108
109pub fn build(app: &AppHandle<Wry>, spec: &MenuSpec) -> Result<BuiltMenu, String> {
110    let mut items = HashMap::new();
111    let menu = Menu::new(app).map_err(s)?;
112
113    #[cfg(target_os = "macos")]
114    {
115        let app_menu = SubmenuBuilder::new(app, &spec.app_name)
116            .about(None)
117            .build()
118            .map_err(s)?;
119        if let Some((id, accel)) = &spec.settings {
120            app_menu
121                .append(&PredefinedMenuItem::separator(app).map_err(s)?)
122                .map_err(s)?;
123            add_entries(
124                app,
125                &app_menu,
126                &[MenuEntry::accel(id, "Settings\u{2026}", accel)],
127                &mut items,
128            )?;
129        }
130        app_menu
131            .append(&PredefinedMenuItem::separator(app).map_err(s)?)
132            .map_err(s)?;
133        app_menu
134            .append(&PredefinedMenuItem::services(app, None).map_err(s)?)
135            .map_err(s)?;
136        app_menu
137            .append(&PredefinedMenuItem::separator(app).map_err(s)?)
138            .map_err(s)?;
139        app_menu
140            .append(&PredefinedMenuItem::hide(app, None).map_err(s)?)
141            .map_err(s)?;
142        app_menu
143            .append(&PredefinedMenuItem::hide_others(app, None).map_err(s)?)
144            .map_err(s)?;
145        app_menu
146            .append(&PredefinedMenuItem::show_all(app, None).map_err(s)?)
147            .map_err(s)?;
148        app_menu
149            .append(&PredefinedMenuItem::separator(app).map_err(s)?)
150            .map_err(s)?;
151        // Not the predefined quit: that terminates without the shell's ordered quit, so with
152        // close-to-tray on a Cmd+Q would hide the window instead of draining. The item routes to
153        // `lifecycle::request_quit` (see `ID_QUIT` and `lifecycle::on_menu_event`).
154        app_menu
155            .append(
156                &MenuItem::with_id(
157                    app,
158                    ID_QUIT,
159                    format!("Quit {}", spec.app_name),
160                    true,
161                    Some("CmdOrCtrl+Q"),
162                )
163                .map_err(s)?,
164            )
165            .map_err(s)?;
166        menu.append(&app_menu).map_err(s)?;
167    }
168    #[cfg(not(target_os = "macos"))]
169    {
170        let file = SubmenuBuilder::new(app, "File").build().map_err(s)?;
171        if let Some((id, accel)) = &spec.settings {
172            add_entries(
173                app,
174                &file,
175                &[
176                    MenuEntry::accel(id, "Settings\u{2026}", accel),
177                    MenuEntry::Separator,
178                ],
179                &mut items,
180            )?;
181        }
182        file.append(&PredefinedMenuItem::close_window(app, None).map_err(s)?)
183            .map_err(s)?;
184        // Routed to `lifecycle::request_quit`, as on macOS (see `ID_QUIT`).
185        file.append(
186            &MenuItem::with_id(
187                app,
188                ID_QUIT,
189                format!("Exit {}", spec.app_name),
190                true,
191                None::<&str>,
192            )
193            .map_err(s)?,
194        )
195        .map_err(s)?;
196        menu.append(&file).map_err(s)?;
197    }
198
199    let edit = SubmenuBuilder::new(app, "Edit")
200        .undo()
201        .redo()
202        .separator()
203        .cut()
204        .copy()
205        .paste()
206        .select_all()
207        .build()
208        .map_err(s)?;
209    menu.append(&edit).map_err(s)?;
210
211    #[cfg(target_os = "macos")]
212    {
213        // Fullscreen is the only View verb; reload/zoom are deliberately absent.
214        let view = SubmenuBuilder::new(app, "View")
215            .fullscreen()
216            .build()
217            .map_err(s)?;
218        menu.append(&view).map_err(s)?;
219    }
220
221    for g in &spec.groups {
222        let sub = SubmenuBuilder::new(app, &g.label).build().map_err(s)?;
223        add_entries(app, &sub, &g.entries, &mut items)?;
224        menu.append(&sub).map_err(s)?;
225    }
226
227    let window = SubmenuBuilder::new(app, "Window")
228        .minimize()
229        .maximize()
230        .separator()
231        .close_window()
232        .build()
233        .map_err(s)?;
234    menu.append(&window).map_err(s)?;
235
236    let help = SubmenuBuilder::new(app, "Help").build().map_err(s)?;
237    add_entries(app, &help, &spec.help, &mut items)?;
238    menu.append(&help).map_err(s)?;
239
240    Ok(BuiltMenu { menu, items })
241}
242
243/// Flatten a menu to `{path -> label}` style rows for diagnostics and tests.
244pub fn describe(menu: &Menu<Wry>) -> Vec<(String, String)> {
245    fn walk(kind: &MenuItemKind<Wry>, prefix: &str, out: &mut Vec<(String, String)>) {
246        match kind {
247            MenuItemKind::Submenu(sm) => {
248                let title = sm.text().unwrap_or_default();
249                let path = format!("{prefix}{title}/");
250                out.push((path.clone(), title));
251                for child in sm.items().unwrap_or_default() {
252                    walk(&child, &path, out);
253                }
254            }
255            MenuItemKind::MenuItem(mi) => out.push((
256                format!("{prefix}{}", mi.id().as_ref()),
257                mi.text().unwrap_or_default(),
258            )),
259            MenuItemKind::Predefined(p) => out.push((
260                format!("{prefix}(predefined)"),
261                p.text().unwrap_or_default(),
262            )),
263            MenuItemKind::Check(c) => out.push((
264                format!("{prefix}{}", c.id().as_ref()),
265                c.text().unwrap_or_default(),
266            )),
267            MenuItemKind::Icon(i) => out.push((
268                format!("{prefix}{}", i.id().as_ref()),
269                i.text().unwrap_or_default(),
270            )),
271        }
272    }
273    let mut out = Vec::new();
274    for item in menu.items().unwrap_or_default() {
275        walk(&item, "", &mut out);
276    }
277    out
278}