Skip to main content

frust_shell_desktop/
config.rs

1//! Desktop app identity: the platform-independent configuration the shared
2//! winit core threads into window creation and carries for the per-OS shell
3//! crates.
4//!
5//! Everything here is plain data — no `winit`, no platform types, no behavior.
6//! The core consumes the window title ([`DesktopConfig::app_name`]) and the
7//! window geometry fields (initial/min/max size, opt-in window-state
8//! persistence); the rest exists so a per-OS shell can read one config
9//! rather than inventing its own (`app_id` becomes a Wayland `app_id`/X11
10//! `WM_CLASS` or a Windows AppUserModelID; `window_icon` becomes an
11//! `NSApplication` icon / `HICON` / X11 icon; `menu_spec` becomes an NSApp menu
12//! bar or an HMENU).
13//!
14//! # Why the menu vocabulary lives here, below the facade
15//!
16//! [`MenuSpec`]/[`MenuItemSpec`] are app-facing types, so the obvious home
17//! looks like the `frust` facade. It cannot be: the facade depends on the shell
18//! crates, never the reverse, and both the shared core (which carries the spec)
19//! and the per-OS shells (which build a native menu from it) need to name the
20//! type. So the vocabulary is *defined* here — the lowest crate that all of
21//! them share — and the facade *re-exports* it, exactly as it re-exports the
22//! reactive seams it likewise cannot own.
23//!
24//! # Defaults are today's behavior
25//!
26//! [`DesktopConfig::default()`] reproduces the zero-config dev-preview window
27//! byte-for-byte: the title falls back to [`DEFAULT_APP_NAME`], no icon, no
28//! menu, no size constraints, no persisted geometry, and a close request
29//! quits. An app that never configures anything gets precisely the window it
30//! got before this seam existed.
31
32use kurbo::Size;
33
34/// The window title used when no [`DesktopConfig::app_name`] is configured —
35/// the dev-preview shell's historical hardcoded title, preserved so the
36/// zero-config path is unchanged.
37pub const DEFAULT_APP_NAME: &str = "Frust";
38
39/// The desktop app's identity and native-integration configuration.
40///
41/// Built by the facade from an app's own declaration and handed to
42/// [`run_desktop_with`](crate::run_desktop_with); every field is optional and
43/// the [`Default`] value is today's dev-preview behavior (see the module docs).
44///
45/// `PartialEq` but not `Eq`: the size fields are `f64`-backed [`Size`]s.
46#[derive(Debug, Clone, PartialEq)]
47pub struct DesktopConfig {
48    /// The app's display name: the window title, and (for the per-OS shells)
49    /// the name a macOS menu bar's application menu shows. `None` falls back to
50    /// [`DEFAULT_APP_NAME`] for the title — see [`DesktopConfig::window_title`].
51    pub app_name: Option<String>,
52    /// The app's reverse-DNS identifier (`com.example.myapp`). Carried, never
53    /// consumed by the shared core: the Linux shell turns it into a Wayland
54    /// `app_id`/X11 `WM_CLASS` so the window pairs with its `.desktop` entry,
55    /// and the Windows shell into an AppUserModelID so taskbar grouping and
56    /// notifications attribute correctly.
57    pub app_id: Option<String>,
58    /// The window/taskbar icon as platform-independent RGBA (see [`IconData`]).
59    /// Carried for the per-OS shells; the shared core never attaches it, since
60    /// winit's own `WindowAttributes::with_window_icon` reaches X11 and Windows
61    /// only and macOS wants an application icon rather than a window one.
62    pub window_icon: Option<IconData>,
63    /// The native menu bar to install, if any (see [`MenuSpec`]). Carried for
64    /// the per-OS shells that can build one; a platform with no native menu
65    /// (Linux, where the menu is widget-drawn) simply ignores it.
66    pub menu_spec: Option<MenuSpec>,
67    /// Whether closing the last window quits the app.
68    ///
69    /// Carried, not consumed by the shared core: with no extension installed a
70    /// close request always exits the event loop (today's behavior, and what
71    /// `true` means anyway). It exists for the macOS shell, where the platform
72    /// convention is for an app to stay running with no window until ⌘Q —
73    /// see `DesktopExtensions::on_close_requested`.
74    pub quit_on_last_window_closed: bool,
75    /// The window's initial inner size in logical pixels, or `None` for the
76    /// shell default (800×600). A persisted window state and the
77    /// `FRUST_WINDOW_SIZE` measurement knob both take precedence over it.
78    pub initial_size: Option<Size>,
79    /// The smallest inner size, in logical pixels, the user can resize the
80    /// window to. Also clamps a restored window state.
81    pub min_size: Option<Size>,
82    /// The largest inner size, in logical pixels, the user can resize the
83    /// window to. Also clamps a restored window state.
84    pub max_size: Option<Size>,
85    /// Whether the window's size, position and maximized state are saved to
86    /// the user's data directory and restored on the next launch. Opt-in
87    /// (`false` by default); a restored position that no longer lands on any
88    /// connected monitor is dropped and the OS places the window instead.
89    pub persist_window_state: bool,
90}
91
92impl Default for DesktopConfig {
93    /// Hand-written rather than derived because
94    /// [`quit_on_last_window_closed`](DesktopConfig::quit_on_last_window_closed)
95    /// defaults to `true` — the shell's existing unconditional
96    /// `event_loop.exit()` on `CloseRequested`.
97    fn default() -> Self {
98        Self {
99            app_name: None,
100            app_id: None,
101            window_icon: None,
102            menu_spec: None,
103            quit_on_last_window_closed: true,
104            initial_size: None,
105            min_size: None,
106            max_size: None,
107            persist_window_state: false,
108        }
109    }
110}
111
112impl DesktopConfig {
113    /// A config that reproduces today's zero-config preview window (see
114    /// [`Default`]).
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// Set the app's display name (the window title).
120    pub fn with_app_name(mut self, app_name: impl Into<String>) -> Self {
121        self.app_name = Some(app_name.into());
122        self
123    }
124
125    /// Set the app's reverse-DNS identifier.
126    pub fn with_app_id(mut self, app_id: impl Into<String>) -> Self {
127        self.app_id = Some(app_id.into());
128        self
129    }
130
131    /// Set the window/taskbar icon.
132    pub fn with_window_icon(mut self, icon: IconData) -> Self {
133        self.window_icon = Some(icon);
134        self
135    }
136
137    /// Set the native menu bar to install.
138    pub fn with_menu_spec(mut self, menu_spec: MenuSpec) -> Self {
139        self.menu_spec = Some(menu_spec);
140        self
141    }
142
143    /// Set whether closing the last window quits the app.
144    pub fn with_quit_on_last_window_closed(mut self, quit: bool) -> Self {
145        self.quit_on_last_window_closed = quit;
146        self
147    }
148
149    /// Set the window's initial inner size, in logical pixels (see
150    /// [`initial_size`](DesktopConfig::initial_size)).
151    pub fn with_initial_size(mut self, size: impl Into<Size>) -> Self {
152        self.initial_size = Some(size.into());
153        self
154    }
155
156    /// Set the window's minimum inner size, in logical pixels.
157    pub fn with_min_size(mut self, size: impl Into<Size>) -> Self {
158        self.min_size = Some(size.into());
159        self
160    }
161
162    /// Set the window's maximum inner size, in logical pixels.
163    pub fn with_max_size(mut self, size: impl Into<Size>) -> Self {
164        self.max_size = Some(size.into());
165        self
166    }
167
168    /// Opt in to (or out of) saving and restoring the window's geometry
169    /// across launches (see
170    /// [`persist_window_state`](DesktopConfig::persist_window_state)).
171    pub fn with_persist_window_state(mut self, persist: bool) -> Self {
172        self.persist_window_state = persist;
173        self
174    }
175
176    /// The title to give the preview window: the configured
177    /// [`app_name`](DesktopConfig::app_name), else [`DEFAULT_APP_NAME`].
178    ///
179    /// The fallback lives here rather than in the field itself so a per-OS
180    /// shell can still tell "the app named itself `Frust`" apart from "the app
181    /// named itself nothing" — a macOS menu bar wants the real name or no
182    /// application menu at all, not a placeholder.
183    pub fn window_title(&self) -> &str {
184        self.app_name.as_deref().unwrap_or(DEFAULT_APP_NAME)
185    }
186}
187
188/// A decoded window/app icon: tightly-packed, non-premultiplied RGBA8 rows,
189/// top-to-bottom — the one representation every desktop platform can be fed
190/// from (winit's `Icon::from_rgba`, an `NSImage` bitmap rep, an `HICON` DIB).
191///
192/// Decoding a PNG/ICNS/ICO into this is the caller's job: this crate carries no
193/// image decoder, and the tooling tier already owns the icon pipeline.
194///
195/// The `rgba`/`width`/`height` triple is an invariant, not three independent
196/// fields (`rgba.len() == width * height * 4`), so the fields are private and
197/// [`IconData::from_rgba`] is the only way in — an inconsistent icon reaches a
198/// platform API as a buffer overrun, not a wrong picture.
199///
200/// `Debug` is hand-written rather than derived (see the manual `impl` below):
201/// a derived one would dump the whole pixel buffer, drowning a log line in
202/// thousands of byte values for even a small icon.
203#[derive(Clone, PartialEq, Eq)]
204pub struct IconData {
205    rgba: Vec<u8>,
206    width: u32,
207    height: u32,
208}
209
210/// No real window/taskbar icon approaches this — winit's own `Icon::from_rgba`
211/// already limits a *Windows* icon to `u16::MAX` per side (65535), and macOS/
212/// Linux icons are conventionally well under 1024px. The cap forecloses an
213/// absurd `width`/`height` (however it arrived — a corrupt decode, a
214/// deliberately hostile input) from reaching a platform icon API at all, on
215/// top of [`from_rgba`](IconData::from_rgba)'s own overflow-safe size check.
216const MAX_ICON_SIDE: u32 = 4096;
217
218impl IconData {
219    /// Wrap decoded RGBA8 pixels, or `None` when they do not describe a
220    /// `width × height` image: either dimension is zero or exceeds
221    /// `MAX_ICON_SIDE`, or `rgba.len() != width * height * 4`.
222    ///
223    /// `Option` rather than a `<Type>Error` enum because there is exactly one
224    /// failure mode and nothing to match on — and this crate carries no
225    /// `thiserror` dependency to add one with (version pins are law). The
226    /// caller that decoded the image is the one holding the context worth
227    /// reporting.
228    ///
229    /// **Check order matters.** The dimension cap runs *before* the size
230    /// arithmetic below it, so a hostile `width`/`height` is rejected on a
231    /// cheap comparison rather than reaching the multiply (or, on a caller
232    /// that already allocated `rgba` to match, whatever cost that
233    /// allocation carried) at all.
234    pub fn from_rgba(rgba: Vec<u8>, width: u32, height: u32) -> Option<Self> {
235        if width == 0 || height == 0 || width > MAX_ICON_SIDE || height > MAX_ICON_SIDE {
236            return None;
237        }
238        // Checked, not `u64::from(..) * u64::from(..) * 4`: a u64 product of
239        // two u32s can't overflow, but a *third* factor can — width = height =
240        // 2^31 multiplies out to exactly 2^64, which wraps to 0 and would
241        // validate an empty buffer against a two-billion-pixel image. The
242        // `MAX_ICON_SIDE` cap above already forecloses this in practice, but
243        // the arithmetic stays checked regardless — a size invariant this
244        // load-bearing must not depend on a second, separate check staying in
245        // sync with it.
246        let expected = u64::from(width)
247            .checked_mul(u64::from(height))
248            .and_then(|pixels| pixels.checked_mul(4))?;
249        if rgba.len() as u64 != expected {
250            return None;
251        }
252        Some(Self {
253            rgba,
254            width,
255            height,
256        })
257    }
258
259    /// The tightly-packed RGBA8 pixels (`width * height * 4` bytes).
260    pub fn rgba(&self) -> &[u8] {
261        &self.rgba
262    }
263
264    /// The image width in pixels (never zero, never above `MAX_ICON_SIDE`).
265    pub fn width(&self) -> u32 {
266        self.width
267    }
268
269    /// The image height in pixels (never zero, never above `MAX_ICON_SIDE`).
270    pub fn height(&self) -> u32 {
271        self.height
272    }
273}
274
275impl std::fmt::Debug for IconData {
276    /// Prints `rgba.len()` rather than the buffer itself (see the type's doc
277    /// comment) — the pixel count is all a log line needs to say.
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        f.debug_struct("IconData")
280            .field("rgba_len", &self.rgba.len())
281            .field("width", &self.width)
282            .field("height", &self.height)
283            .finish()
284    }
285}
286
287/// A native menu, as a platform-independent tree: the top-level value is the
288/// menu *bar* (whose items are conventionally [`MenuItemSpec::Submenu`]s), and
289/// the same type describes each submenu below it.
290///
291/// Nothing here is rendered by Frust — a per-OS shell translates it into the
292/// host's own menu API, and reports an activation back through
293/// `frust_reactive::push_menu_event` keyed by the activated item's
294/// [`id`](MenuItemSpec::Item::id).
295#[derive(Debug, Clone, Default, PartialEq, Eq)]
296pub struct MenuSpec {
297    /// The menu's items, in display order.
298    pub items: Vec<MenuItemSpec>,
299}
300
301impl MenuSpec {
302    /// An empty menu.
303    pub fn new() -> Self {
304        Self::default()
305    }
306
307    /// Append one item, builder-style.
308    pub fn with_item(mut self, item: MenuItemSpec) -> Self {
309        self.items.push(item);
310        self
311    }
312
313    /// Whether the menu has no items — a shell installs nothing at all rather
314    /// than an empty menu bar.
315    pub fn is_empty(&self) -> bool {
316        self.items.is_empty()
317    }
318}
319
320/// One entry in a [`MenuSpec`].
321///
322/// Deliberately **not** `#[non_exhaustive]`: a per-OS shell must match it
323/// exhaustively, so a new variant is a compile error in every shell that would
324/// otherwise silently drop the item from the menu it builds.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub enum MenuItemSpec {
327    /// An app-defined item. Activating it pushes [`id`](MenuItemSpec::Item::id)
328    /// through `frust_reactive::push_menu_event`, which app code observes via
329    /// the facade's `menu_events()`.
330    Item {
331        /// The app's own id for this item, echoed back verbatim on activation.
332        id: String,
333        /// The text shown in the menu.
334        label: String,
335        /// An accelerator in the cross-platform `"CmdOrCtrl+Shift+P"` shorthand
336        /// (`Cmd`/`Ctrl`/`Alt`/`Shift` + a key, joined by `+`), or `None` for
337        /// no keyboard shortcut.
338        ///
339        /// Kept a string rather than a parsed chord type because this crate
340        /// binds no menu library: the per-OS shell parses it with whatever its
341        /// menu backend already accepts, and is also where an unparseable
342        /// accelerator is reported — validating it twice, in two vocabularies,
343        /// would only let the two disagree.
344        accelerator: Option<String>,
345        /// Whether the item is selectable. `false` renders it greyed out.
346        enabled: bool,
347    },
348    /// A platform-standard item ([`MenuRole`]) the host implements itself —
349    /// About/Hide/Quit and friends. No activation is reported for these: the
350    /// platform performs the action, so app code has nothing to handle.
351    Role {
352        /// Which standard action this item performs.
353        role: MenuRole,
354        /// An override for the platform's own label, or `None` to use it (the
355        /// normal case — a localized system label beats a hand-written one).
356        label: Option<String>,
357    },
358    /// A separator line.
359    Separator,
360    /// A nested menu.
361    Submenu {
362        /// The text shown for the submenu itself.
363        label: String,
364        /// The submenu's own contents.
365        menu: MenuSpec,
366    },
367}
368
369impl MenuItemSpec {
370    /// An enabled, accelerator-free app item.
371    pub fn item(id: impl Into<String>, label: impl Into<String>) -> Self {
372        Self::Item {
373            id: id.into(),
374            label: label.into(),
375            accelerator: None,
376            enabled: true,
377        }
378    }
379
380    /// A platform-standard item at the platform's own label.
381    pub fn role(role: MenuRole) -> Self {
382        Self::Role { role, label: None }
383    }
384
385    /// A separator line.
386    pub fn separator() -> Self {
387        Self::Separator
388    }
389
390    /// A nested menu under `label`.
391    pub fn submenu(label: impl Into<String>, menu: MenuSpec) -> Self {
392        Self::Submenu {
393            label: label.into(),
394            menu,
395        }
396    }
397
398    /// Attach a keyboard accelerator (see
399    /// [`Item::accelerator`](MenuItemSpec::Item::accelerator)).
400    ///
401    /// Returns the item unchanged for every other variant: a role item's
402    /// shortcut is the platform's own (⌘Q for Quit), and a separator/submenu
403    /// has nothing to activate.
404    pub fn with_accelerator(mut self, accelerator: impl Into<String>) -> Self {
405        if let Self::Item {
406            accelerator: slot, ..
407        } = &mut self
408        {
409            *slot = Some(accelerator.into());
410        }
411        self
412    }
413
414    /// Mark the item greyed out. Returns the item unchanged for every variant
415    /// but [`MenuItemSpec::Item`] (see [`with_accelerator`](Self::with_accelerator)).
416    pub fn disabled(mut self) -> Self {
417        if let Self::Item { enabled, .. } = &mut self {
418            *enabled = false;
419        }
420        self
421    }
422
423    /// Override the label of a [`MenuItemSpec::Role`] item. Returns the item
424    /// unchanged for every other variant (their labels are set at
425    /// construction).
426    pub fn with_label(mut self, label: impl Into<String>) -> Self {
427        if let Self::Role { label: slot, .. } = &mut self {
428            *slot = Some(label.into());
429        }
430        self
431    }
432
433    /// The activation id this item reports, or `None` for an item that reports
434    /// none (a role item, a separator, a submenu).
435    pub fn id(&self) -> Option<&str> {
436        match self {
437            Self::Item { id, .. } => Some(id),
438            Self::Role { .. } | Self::Separator | Self::Submenu { .. } => None,
439        }
440    }
441}
442
443/// A platform-standard menu action the host implements itself.
444///
445/// The set is the one a macOS application menu is expected to carry (the
446/// platform whose menu conventions are strictest); a host that has no notion of
447/// a given role simply omits the item rather than faking it.
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449pub enum MenuRole {
450    /// Show the standard about panel.
451    About,
452    /// Hide the application.
453    Hide,
454    /// Hide every other application.
455    HideOthers,
456    /// Show every hidden application.
457    ShowAll,
458    /// Minimize the focused window.
459    Minimize,
460    /// Close the focused window (which is a close *request* — see
461    /// `DesktopExtensions::on_close_requested`).
462    CloseWindow,
463    /// Quit the application.
464    Quit,
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    // --- DesktopConfig defaults ---
472
473    #[test]
474    fn the_default_config_reproduces_todays_preview_window() {
475        let config = DesktopConfig::default();
476        // The historical hardcoded title, now the documented fallback.
477        assert_eq!(config.window_title(), "Frust");
478        assert_eq!(config.app_name, None);
479        assert_eq!(config.app_id, None);
480        assert_eq!(config.window_icon, None);
481        assert_eq!(config.menu_spec, None);
482        // A close request quits, exactly as the unconditional
483        // `event_loop.exit()` did before this seam existed.
484        assert!(config.quit_on_last_window_closed);
485        assert_eq!(config.initial_size, None);
486        assert_eq!(config.min_size, None);
487        assert_eq!(config.max_size, None);
488        assert!(!config.persist_window_state, "persistence is opt-in");
489        assert_eq!(DesktopConfig::new(), config);
490    }
491
492    #[test]
493    fn the_geometry_builders_set_logical_sizes_and_the_persistence_flag() {
494        let config = DesktopConfig::new()
495            .with_initial_size((1024.0, 768.0))
496            .with_min_size(Size::new(400.0, 300.0))
497            .with_max_size((1920.0, 1080.0))
498            .with_persist_window_state(true);
499        assert_eq!(config.initial_size, Some(Size::new(1024.0, 768.0)));
500        assert_eq!(config.min_size, Some(Size::new(400.0, 300.0)));
501        assert_eq!(config.max_size, Some(Size::new(1920.0, 1080.0)));
502        assert!(config.persist_window_state);
503    }
504
505    #[test]
506    fn a_configured_app_name_becomes_the_window_title() {
507        let config = DesktopConfig::new().with_app_name("Huddle");
508        assert_eq!(config.window_title(), "Huddle");
509        assert_eq!(config.app_name.as_deref(), Some("Huddle"));
510    }
511
512    #[test]
513    fn the_builders_set_each_field_independently() {
514        let icon = IconData::from_rgba(vec![0; 4], 1, 1).expect("1x1 RGBA is valid");
515        let config = DesktopConfig::new()
516            .with_app_name("Huddle")
517            .with_app_id("dev.frust.huddle")
518            .with_window_icon(icon.clone())
519            .with_menu_spec(MenuSpec::new().with_item(MenuItemSpec::role(MenuRole::Quit)))
520            .with_quit_on_last_window_closed(false);
521
522        assert_eq!(config.app_id.as_deref(), Some("dev.frust.huddle"));
523        assert_eq!(config.window_icon, Some(icon));
524        assert_eq!(config.menu_spec.map(|m| m.items.len()), Some(1));
525        assert!(!config.quit_on_last_window_closed);
526    }
527
528    // --- IconData's invariant ---
529
530    #[test]
531    fn icon_data_accepts_exactly_width_times_height_times_four_bytes() {
532        let icon = IconData::from_rgba(vec![7; 2 * 3 * 4], 2, 3).expect("2x3 RGBA is valid");
533        assert_eq!(icon.width(), 2);
534        assert_eq!(icon.height(), 3);
535        assert_eq!(icon.rgba().len(), 24);
536    }
537
538    #[test]
539    fn icon_data_rejects_a_buffer_that_does_not_match_its_dimensions() {
540        // One byte short of a 2x2 image — the case that reaches a platform API
541        // as a buffer overrun rather than a wrong picture.
542        assert_eq!(IconData::from_rgba(vec![0; 15], 2, 2), None);
543        assert_eq!(IconData::from_rgba(vec![0; 17], 2, 2), None);
544    }
545
546    #[test]
547    fn icon_data_rejects_a_zero_dimension() {
548        assert_eq!(IconData::from_rgba(Vec::new(), 0, 4), None);
549        assert_eq!(IconData::from_rgba(Vec::new(), 4, 0), None);
550    }
551
552    #[test]
553    fn icon_data_rejects_a_size_product_that_overflows_u64() {
554        // width = height = 2^31: the naive `u64::from(w) * u64::from(h) * 4`
555        // multiplies out to exactly 2^64, which wraps to 0 and would validate
556        // an empty buffer against a two-billion-pixel image. The dimension cap
557        // rejects this long before the multiply would even run, but the
558        // checked arithmetic is what actually closes the overflow — assert
559        // `None`, not just "doesn't panic": a debug build already panics on
560        // unchecked overflow, so the real regression this guards is a release
561        // build silently wrapping to a validated `Some`.
562        assert_eq!(IconData::from_rgba(Vec::new(), 1 << 31, 1 << 31), None);
563    }
564
565    #[test]
566    fn icon_data_rejects_a_dimension_just_over_the_cap() {
567        assert_eq!(IconData::from_rgba(Vec::new(), MAX_ICON_SIDE + 1, 1), None);
568        assert_eq!(IconData::from_rgba(Vec::new(), 1, MAX_ICON_SIDE + 1), None);
569    }
570
571    #[test]
572    fn icon_data_accepts_the_max_allowed_dimension() {
573        // A real `MAX_ICON_SIDE`-square buffer (67MB) is wasteful to allocate
574        // just to prove the cap's boundary is inclusive — a `MAX_ICON_SIDE ×
575        // 1` strip already exercises the same `width == MAX_ICON_SIDE` cap
576        // comparison, at a 16KB buffer instead.
577        let icon = IconData::from_rgba(vec![0; MAX_ICON_SIDE as usize * 4], MAX_ICON_SIDE, 1)
578            .expect("MAX_ICON_SIDE is inclusive, not an exclusive bound");
579        assert_eq!(icon.width(), MAX_ICON_SIDE);
580        assert_eq!(icon.height(), 1);
581    }
582
583    // --- MenuSpec construction ---
584
585    #[test]
586    fn a_fresh_menu_spec_is_empty() {
587        let menu = MenuSpec::new();
588        assert!(menu.is_empty());
589        assert_eq!(menu, MenuSpec::default());
590    }
591
592    #[test]
593    fn menu_items_build_the_full_item_vocabulary() {
594        let file = MenuSpec::new()
595            .with_item(MenuItemSpec::item("file.open", "Open…").with_accelerator("CmdOrCtrl+O"))
596            .with_item(MenuItemSpec::item("file.export", "Export").disabled())
597            .with_item(MenuItemSpec::separator())
598            .with_item(MenuItemSpec::role(MenuRole::Quit));
599        let bar = MenuSpec::new().with_item(MenuItemSpec::submenu("File", file.clone()));
600
601        assert!(!bar.is_empty());
602        assert_eq!(
603            bar.items,
604            vec![MenuItemSpec::Submenu {
605                label: "File".to_string(),
606                menu: file,
607            }]
608        );
609    }
610
611    #[test]
612    fn an_app_item_carries_its_id_accelerator_and_enabled_state() {
613        let item = MenuItemSpec::item("file.open", "Open…").with_accelerator("CmdOrCtrl+O");
614        assert_eq!(
615            item,
616            MenuItemSpec::Item {
617                id: "file.open".to_string(),
618                label: "Open…".to_string(),
619                accelerator: Some("CmdOrCtrl+O".to_string()),
620                enabled: true,
621            }
622        );
623        assert_eq!(item.id(), Some("file.open"));
624        assert!(matches!(
625            item.disabled(),
626            MenuItemSpec::Item { enabled: false, .. }
627        ));
628    }
629
630    #[test]
631    fn only_app_items_report_an_activation_id() {
632        // The shell pushes `push_menu_event(id)` for exactly these: a role item
633        // is performed by the platform and a separator/submenu activates
634        // nothing.
635        assert_eq!(MenuItemSpec::role(MenuRole::About).id(), None);
636        assert_eq!(MenuItemSpec::separator().id(), None);
637        assert_eq!(MenuItemSpec::submenu("File", MenuSpec::new()).id(), None);
638    }
639
640    #[test]
641    fn the_item_builders_leave_the_wrong_variant_untouched() {
642        // Documented no-ops (see each builder's doc comment), pinned so a
643        // future edit can't silently start mutating a role/separator instead.
644        let role = MenuItemSpec::role(MenuRole::Quit);
645        assert_eq!(role.clone().with_accelerator("CmdOrCtrl+Q"), role);
646        assert_eq!(role.clone().disabled(), role);
647        assert_eq!(MenuItemSpec::separator().with_label("nope"), {
648            MenuItemSpec::Separator
649        });
650    }
651
652    #[test]
653    fn a_role_item_can_override_the_platform_label() {
654        assert_eq!(
655            MenuItemSpec::role(MenuRole::About).with_label("About Huddle"),
656            MenuItemSpec::Role {
657                role: MenuRole::About,
658                label: Some("About Huddle".to_string()),
659            }
660        );
661    }
662}