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