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
29fn yes() -> bool {
30    true
31}
32
33impl MenuEntry {
34    pub fn item(id: &str, label: &str) -> Self {
35        MenuEntry::Item {
36            id: id.into(),
37            label: label.into(),
38            accelerator: None,
39            enabled: true,
40        }
41    }
42    pub fn accel(id: &str, label: &str, accelerator: &str) -> Self {
43        MenuEntry::Item {
44            id: id.into(),
45            label: label.into(),
46            accelerator: Some(accelerator.into()),
47            enabled: true,
48        }
49    }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct MenuGroup {
54    pub label: String,
55    pub entries: Vec<MenuEntry>,
56}
57
58#[derive(Debug, Clone, Default)]
59pub struct MenuSpec {
60    pub app_name: String,
61    /// Extra top-level menus, placed after Edit/View.
62    pub groups: Vec<MenuGroup>,
63    /// Entries appended to Help.
64    pub help: Vec<MenuEntry>,
65    /// `Some((id, accelerator))` adds "Settings…" to the app menu (macOS) / File menu.
66    pub settings: Option<(String, String)>,
67}
68
69pub struct BuiltMenu {
70    pub menu: Menu<Wry>,
71    pub items: HashMap<String, MenuItem<Wry>>,
72}
73
74fn add_entries(
75    app: &AppHandle<Wry>,
76    sub: &Submenu<Wry>,
77    entries: &[MenuEntry],
78    items: &mut HashMap<String, MenuItem<Wry>>,
79) -> Result<(), String> {
80    for e in entries {
81        match e {
82            MenuEntry::Separator => sub
83                .append(&PredefinedMenuItem::separator(app).map_err(s)?)
84                .map_err(s)?,
85            MenuEntry::Item {
86                id,
87                label,
88                accelerator,
89                enabled,
90            } => {
91                let mi = MenuItem::with_id(app, id, label, *enabled, accelerator.as_deref())
92                    .map_err(s)?;
93                sub.append(&mi).map_err(s)?;
94                items.insert(id.clone(), mi);
95            }
96        }
97    }
98    Ok(())
99}
100
101fn s<E: std::fmt::Display>(e: E) -> String {
102    e.to_string()
103}
104
105pub fn build(app: &AppHandle<Wry>, spec: &MenuSpec) -> Result<BuiltMenu, String> {
106    let mut items = HashMap::new();
107    let menu = Menu::new(app).map_err(s)?;
108
109    #[cfg(target_os = "macos")]
110    {
111        let app_menu = SubmenuBuilder::new(app, &spec.app_name)
112            .about(None)
113            .build()
114            .map_err(s)?;
115        if let Some((id, accel)) = &spec.settings {
116            app_menu
117                .append(&PredefinedMenuItem::separator(app).map_err(s)?)
118                .map_err(s)?;
119            add_entries(
120                app,
121                &app_menu,
122                &[MenuEntry::accel(id, "Settings\u{2026}", accel)],
123                &mut items,
124            )?;
125        }
126        app_menu
127            .append(&PredefinedMenuItem::separator(app).map_err(s)?)
128            .map_err(s)?;
129        app_menu
130            .append(&PredefinedMenuItem::services(app, None).map_err(s)?)
131            .map_err(s)?;
132        app_menu
133            .append(&PredefinedMenuItem::separator(app).map_err(s)?)
134            .map_err(s)?;
135        app_menu
136            .append(&PredefinedMenuItem::hide(app, None).map_err(s)?)
137            .map_err(s)?;
138        app_menu
139            .append(&PredefinedMenuItem::hide_others(app, None).map_err(s)?)
140            .map_err(s)?;
141        app_menu
142            .append(&PredefinedMenuItem::show_all(app, None).map_err(s)?)
143            .map_err(s)?;
144        app_menu
145            .append(&PredefinedMenuItem::separator(app).map_err(s)?)
146            .map_err(s)?;
147        app_menu
148            .append(
149                &PredefinedMenuItem::quit(app, Some(&format!("Quit {}", spec.app_name)))
150                    .map_err(s)?,
151            )
152            .map_err(s)?;
153        menu.append(&app_menu).map_err(s)?;
154    }
155    #[cfg(not(target_os = "macos"))]
156    {
157        let file = SubmenuBuilder::new(app, "File").build().map_err(s)?;
158        if let Some((id, accel)) = &spec.settings {
159            add_entries(
160                app,
161                &file,
162                &[
163                    MenuEntry::accel(id, "Settings\u{2026}", accel),
164                    MenuEntry::Separator,
165                ],
166                &mut items,
167            )?;
168        }
169        file.append(&PredefinedMenuItem::close_window(app, None).map_err(s)?)
170            .map_err(s)?;
171        file.append(
172            &PredefinedMenuItem::quit(app, Some(&format!("Exit {}", spec.app_name))).map_err(s)?,
173        )
174        .map_err(s)?;
175        menu.append(&file).map_err(s)?;
176    }
177
178    let edit = SubmenuBuilder::new(app, "Edit")
179        .undo()
180        .redo()
181        .separator()
182        .cut()
183        .copy()
184        .paste()
185        .select_all()
186        .build()
187        .map_err(s)?;
188    menu.append(&edit).map_err(s)?;
189
190    #[cfg(target_os = "macos")]
191    {
192        // Fullscreen is the only View verb; reload/zoom are deliberately absent.
193        let view = SubmenuBuilder::new(app, "View")
194            .fullscreen()
195            .build()
196            .map_err(s)?;
197        menu.append(&view).map_err(s)?;
198    }
199
200    for g in &spec.groups {
201        let sub = SubmenuBuilder::new(app, &g.label).build().map_err(s)?;
202        add_entries(app, &sub, &g.entries, &mut items)?;
203        menu.append(&sub).map_err(s)?;
204    }
205
206    let window = SubmenuBuilder::new(app, "Window")
207        .minimize()
208        .maximize()
209        .separator()
210        .close_window()
211        .build()
212        .map_err(s)?;
213    menu.append(&window).map_err(s)?;
214
215    let help = SubmenuBuilder::new(app, "Help").build().map_err(s)?;
216    add_entries(app, &help, &spec.help, &mut items)?;
217    menu.append(&help).map_err(s)?;
218
219    Ok(BuiltMenu { menu, items })
220}
221
222/// Flatten a menu to `{path -> label}` style rows for diagnostics and tests.
223pub fn describe(menu: &Menu<Wry>) -> Vec<(String, String)> {
224    fn walk(kind: &MenuItemKind<Wry>, prefix: &str, out: &mut Vec<(String, String)>) {
225        match kind {
226            MenuItemKind::Submenu(sm) => {
227                let title = sm.text().unwrap_or_default();
228                let path = format!("{prefix}{title}/");
229                out.push((path.clone(), title));
230                for child in sm.items().unwrap_or_default() {
231                    walk(&child, &path, out);
232                }
233            }
234            MenuItemKind::MenuItem(mi) => out.push((
235                format!("{prefix}{}", mi.id().as_ref()),
236                mi.text().unwrap_or_default(),
237            )),
238            MenuItemKind::Predefined(p) => out.push((
239                format!("{prefix}(predefined)"),
240                p.text().unwrap_or_default(),
241            )),
242            MenuItemKind::Check(c) => out.push((
243                format!("{prefix}{}", c.id().as_ref()),
244                c.text().unwrap_or_default(),
245            )),
246            MenuItemKind::Icon(i) => out.push((
247                format!("{prefix}{}", i.id().as_ref()),
248                i.text().unwrap_or_default(),
249            )),
250        }
251    }
252    let mut out = Vec::new();
253    for item in menu.items().unwrap_or_default() {
254        walk(&item, "", &mut out);
255    }
256    out
257}