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}