Skip to main content

frus_widgets/
menubar.rs

1//! [`MenuBar`], [`SubmenuButton`] and [`MenuPath`]: the menus along the top of a desktop
2//! window, and the submenus inside them (milestone 603).
3//!
4//! # Where "which menu is open" lives
5//!
6//! In the application, as a [`MenuPath`]: one field, and one message that carries the new
7//! path. That is how every other menu here works — a [`PopupMenuButton`](crate::PopupMenuButton)
8//! and a [`MenuAnchor`](crate::MenuAnchor) are told whether they are open — and it keeps
9//! `update` the one place the interface's state changes, testable without a window. A menu
10//! bar's state is a path rather than a flag, which is the whole difference, so the path is a
11//! type of its own with the moves a menu makes on it.
12//!
13//! An application built from components keeps the path in a hook, as it would a flag.
14
15use std::rc::Rc;
16
17use frus_core::{Color, Insets, Point, Rect, Scene, TextStyle};
18use frus_layout::{Align, Dimension, FlexDirection, Style};
19
20use crate::interaction::Status;
21use crate::menu::{menu_panel, MenuItem, PanelKind, PanelStyle, RowKeys, RowLook};
22use crate::portal::{OverlayPortal, Placement};
23use crate::theme::Theme;
24use crate::widget::Widget;
25
26/// **Which menu of a [`MenuBar`] is open, and how deep**: the index of the open menu on the
27/// bar, then of the open submenu in it, and so on. Empty when every menu is closed.
28#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
29pub struct MenuPath(Vec<usize>);
30
31impl MenuPath {
32    /// Every menu closed.
33    pub fn closed() -> Self {
34        Self(Vec::new())
35    }
36
37    /// The path of indices, from the bar down.
38    pub fn from_indices(indices: impl Into<Vec<usize>>) -> Self {
39        Self(indices.into())
40    }
41
42    /// Whether any menu is open.
43    pub fn is_open(&self) -> bool {
44        !self.0.is_empty()
45    }
46
47    /// How many menus are open: `1` for a menu off the bar, `2` with a submenu in it.
48    pub fn depth(&self) -> usize {
49        self.0.len()
50    }
51
52    /// Which entry is open at `level` — `0` the bar.
53    pub fn at(&self, level: usize) -> Option<usize> {
54        self.0.get(level).copied()
55    }
56
57    /// The indices, from the bar down.
58    pub fn indices(&self) -> &[usize] {
59        &self.0
60    }
61
62    /// The path with entry `index` open at `level`, and whatever was open below it closed.
63    pub fn opened(&self, level: usize, index: usize) -> Self {
64        let mut path = self.0[..level.min(self.0.len())].to_vec();
65        path.push(index);
66        Self(path)
67    }
68
69    /// The path with everything from `level` down closed: `truncated(0)` closes the lot.
70    pub fn truncated(&self, level: usize) -> Self {
71        Self(self.0[..level.min(self.0.len())].to_vec())
72    }
73
74    /// One level fewer open: what Escape does.
75    pub fn up(&self) -> Self {
76        self.truncated(self.0.len().saturating_sub(1))
77    }
78}
79
80/// **What a caller says about a menu bar**, every word optional: what is unset is the
81/// theme's ([`MenuBarTheme`](crate::MenuBarTheme)), then the platform's (milestone 636).
82#[derive(Clone, Copy, Debug, Default, PartialEq)]
83struct BarStyle {
84    height: Option<f32>,
85    background: Option<Color>,
86    padding: Option<Insets>,
87    item_padding: Option<Insets>,
88    text_style: Option<TextStyle>,
89    foreground: Option<Color>,
90    highlight: Option<Color>,
91    item_radius: Option<f32>,
92    item_inset: Option<f32>,
93    menus: crate::MenuTheme,
94}
95
96/// A bar's look, resolved for one theme.
97#[derive(Clone, Copy, Debug, PartialEq)]
98struct BarLook {
99    height: f32,
100    background: Color,
101    padding: Insets,
102    item_padding: Insets,
103    text_style: TextStyle,
104    foreground: Color,
105    highlight: Option<Color>,
106    item_radius: f32,
107    item_inset: f32,
108}
109
110impl BarStyle {
111    /// The caller's word, then the theme's, then a desktop's bar on a desktop theme — 30 px,
112    /// 13 px words 8 px either side, a 4 px rounded highlight 3 px inside the bar — or a
113    /// phone's on a phone's theme, 48 px for a finger.
114    fn resolve(&self, theme: &Theme) -> BarLook {
115        let t = &theme.widgets.menu_bar;
116        let desktop = theme.platform.is_desktop();
117        BarLook {
118            height: self.height.or(t.height).unwrap_or(if desktop {
119                30.0
120            } else {
121                crate::theme::MIN_TAP_TARGET
122            }),
123            background: self
124                .background
125                .or(t.background)
126                .unwrap_or(theme.scheme.surface_container),
127            padding: self.padding.or(t.padding).unwrap_or(if desktop {
128                Insets::new(0.0, 4.0, 0.0, 4.0)
129            } else {
130                Insets::ZERO
131            }),
132            item_padding: self.item_padding.or(t.item_padding).unwrap_or(if desktop {
133                Insets::new(0.0, 8.0, 0.0, 8.0)
134            } else {
135                Insets::new(0.0, BAR_PAD_X, 0.0, BAR_PAD_X)
136            }),
137            text_style: self.text_style.or(t.text_style).unwrap_or(if desktop {
138                TextStyle::new(13.0)
139            } else {
140                theme.text.label_large
141            }),
142            foreground: self
143                .foreground
144                .or(t.foreground)
145                .unwrap_or(theme.scheme.on_surface),
146            highlight: self.highlight.or(t.highlight),
147            item_radius: self.item_radius.or(t.item_radius).unwrap_or(if desktop {
148                4.0
149            } else {
150                0.0
151            }),
152            item_inset: self
153                .item_inset
154                .or(t.item_inset)
155                .unwrap_or(if desktop { 3.0 } else { 0.0 }),
156        }
157    }
158}
159
160/// One entry of a menu: an action, a rule, or a submenu.
161enum Entry<Msg> {
162    Item(MenuItem<Msg>),
163    Submenu(SubmenuButton<Msg>),
164}
165
166/// **A menu**: on a [`MenuBar`], the word along the top and what drops from it; inside
167/// another menu, a row that opens a menu beside it.
168///
169/// Its rows are [`MenuItem`]s: actions, ticked rows, rows with a shortcut shown, rules.
170///
171/// ```
172/// use frus_widgets::{MenuItem, SubmenuButton};
173///
174/// #[derive(Clone)]
175/// enum Msg { New, Open, Recent(usize) }
176///
177/// let file: SubmenuButton<Msg> = SubmenuButton::new("File")
178///     .item(MenuItem::new("New", Msg::New).shortcut("Ctrl+N"))
179///     .item(MenuItem::new("Open…", Msg::Open))
180///     .divider()
181///     .submenu(
182///         SubmenuButton::new("Open recent")
183///             .item(MenuItem::new("notes.txt", Msg::Recent(0)))
184///             .item(MenuItem::new("todo.md", Msg::Recent(1))),
185///     );
186/// ```
187pub struct SubmenuButton<Msg = crate::callback::Callback> {
188    label: String,
189    entries: Vec<Entry<Msg>>,
190    enabled: bool,
191}
192
193impl<Msg> SubmenuButton<Msg> {
194    /// A menu called `label`, with nothing in it yet.
195    pub fn new(label: impl Into<String>) -> Self {
196        Self {
197            label: label.into(),
198            entries: Vec::new(),
199            enabled: true,
200        }
201    }
202
203    /// Adds a row.
204    pub fn item(mut self, item: MenuItem<Msg>) -> Self {
205        self.entries.push(Entry::Item(item));
206        self
207    }
208
209    /// Adds a row that opens `submenu` beside this menu.
210    pub fn submenu(mut self, submenu: SubmenuButton<Msg>) -> Self {
211        self.entries.push(Entry::Submenu(submenu));
212        self
213    }
214
215    /// Adds the rule between two groups.
216    pub fn divider(mut self) -> Self {
217        self.entries.push(Entry::Item(MenuItem::divider()));
218        self
219    }
220
221    /// Whether the menu can be opened. A disabled one is still shown, greyed.
222    pub fn enabled(mut self, enabled: bool) -> Self {
223        self.enabled = enabled;
224        self
225    }
226}
227
228/// The message a path is turned into.
229type OnPath<Msg> = Rc<dyn Fn(MenuPath) -> Msg>;
230
231/// **The menus along the top of a window**: File, Edit, View.
232///
233/// The application holds which one is open as a [`MenuPath`] and is sent the new path
234/// whenever it should change:
235///
236/// - a press on a menu's word opens it, and a second press closes it;
237/// - once one is open, moving the pointer onto another word opens that one instead, with no
238///   press;
239/// - moving onto a row that opens a submenu opens it, and moving onto any other row of that
240///   menu closes it again;
241/// - a press outside every open menu closes them all.
242///
243/// A row's own message is sent when it is pressed. **Close the menus when you handle it**
244/// (`self.menu = MenuPath::closed()`), as a [`PopupMenuButton`](crate::PopupMenuButton)'s
245/// rows are closed: a press is one message, and it is the row's.
246///
247/// ```
248/// use frus_widgets::{MenuBar, MenuItem, MenuPath, SubmenuButton};
249///
250/// #[derive(Clone)]
251/// enum Msg { Menu(MenuPath), Quit, Wrap }
252///
253/// let path = MenuPath::closed();
254/// let bar = MenuBar::new(&path, Msg::Menu)
255///     .menu(SubmenuButton::new("File").item(MenuItem::new("Quit", Msg::Quit)))
256///     .menu(SubmenuButton::new("View").item(MenuItem::checked("Word wrap", true, Msg::Wrap)));
257/// # let _: MenuBar<Msg> = bar;
258/// ```
259pub struct MenuBar<Msg = crate::callback::Callback> {
260    path: MenuPath,
261    on_path: OnPath<Msg>,
262    menus: Vec<SubmenuButton<Msg>>,
263    style: BarStyle,
264    children: Vec<Box<dyn Widget<Msg>>>,
265}
266
267impl<Msg: Clone + 'static> MenuBar<Msg> {
268    /// A bar with `path` open, sending `on_path` the path it should have next.
269    pub fn new(path: &MenuPath, on_path: impl Fn(MenuPath) -> Msg + 'static) -> Self {
270        Self {
271            path: path.clone(),
272            on_path: Rc::new(on_path),
273            menus: Vec::new(),
274            style: BarStyle::default(),
275            children: Vec::new(),
276        }
277    }
278
279    /// Builds the words again, so that a style said after the menus reaches them.
280    fn rebuilt(mut self) -> Self {
281        self.children = (0..self.menus.len()).map(|i| self.word(i)).collect();
282        self
283    }
284
285    /// The height the caller gave the bar, if any.
286    pub(crate) fn given_height(&self) -> Option<f32> {
287        self.style.height
288    }
289
290    /// **How tall the bar is.** Unset, the theme's, then 30 on a desktop and 48 on a phone.
291    #[must_use]
292    pub fn height(mut self, height: f32) -> Self {
293        self.style.height = Some(height);
294        self.rebuilt()
295    }
296
297    /// **The bar's surface.** Unset, the theme's, then `surface_container`.
298    #[must_use]
299    pub fn background(mut self, color: Color) -> Self {
300        self.style.background = Some(color);
301        self.rebuilt()
302    }
303
304    /// **The room at the bar's ends**, around the words.
305    #[must_use]
306    pub fn padding(mut self, padding: Insets) -> Self {
307        self.style.padding = Some(padding);
308        self.rebuilt()
309    }
310
311    /// **The room either side of a word.**
312    #[must_use]
313    pub fn item_padding(mut self, padding: Insets) -> Self {
314        self.style.item_padding = Some(padding);
315        self.rebuilt()
316    }
317
318    /// **The words' type.** Unset, the theme's, then 13 px on a desktop and `label_large` on
319    /// a phone.
320    #[must_use]
321    pub fn text_style(mut self, style: TextStyle) -> Self {
322        self.style.text_style = Some(style);
323        self.rebuilt()
324    }
325
326    /// **The words' colour.** Unset, the theme's, then `on_surface`.
327    #[must_use]
328    pub fn foreground_color(mut self, color: Color) -> Self {
329        self.style.foreground = Some(color);
330        self.rebuilt()
331    }
332
333    /// **The highlight** behind the word under the pointer and the word whose menu is open.
334    /// Unset, the theme's, then `on_surface` over the bar.
335    #[must_use]
336    pub fn highlight_color(mut self, color: Color) -> Self {
337        self.style.highlight = Some(color);
338        self.rebuilt()
339    }
340
341    /// **The highlight's corners.** Unset, the theme's, then 4 on a desktop.
342    #[must_use]
343    pub fn item_radius(mut self, radius: f32) -> Self {
344        self.style.item_radius = Some(radius);
345        self.rebuilt()
346    }
347
348    /// **The room the highlight keeps above and below it**, inside the bar.
349    #[must_use]
350    pub fn item_inset(mut self, inset: f32) -> Self {
351        self.style.item_inset = Some(inset);
352        self.rebuilt()
353    }
354
355    /// **The menus the bar opens**: their rows, their type, their panel. What it says
356    /// outranks the theme's bar menus, a desktop's menus and the application's
357    /// [`MenuTheme`](crate::MenuTheme).
358    #[must_use]
359    pub fn menu_style(mut self, menus: crate::MenuTheme) -> Self {
360        self.style.menus = menus;
361        self.rebuilt()
362    }
363
364    /// Adds a menu to the bar.
365    pub fn menu(mut self, menu: SubmenuButton<Msg>) -> Self {
366        self.menus.push(menu);
367        // Built again whole: the arrows on each word and row name the menus beside it, and
368        // a menu added last is beside the first.
369        self.children = (0..self.menus.len()).map(|i| self.word(i)).collect();
370        self
371    }
372
373    /// The enabled menu `step` places along from `index`, round the ends; `None` when
374    /// there is no other.
375    fn beside(&self, index: usize, step: isize) -> Option<usize> {
376        let count = self.menus.len() as isize;
377        (1..count)
378            .map(|k| (index as isize + step * k).rem_euclid(count) as usize)
379            .find(|&i| self.menus[i].enabled)
380    }
381
382    /// The word for menu `index`, with its menu floating under it when it is open.
383    fn word(&self, index: usize) -> Box<dyn Widget<Msg>> {
384        let menu = &self.menus[index];
385        let on_path = &self.on_path;
386        let open = menu.enabled && self.path.at(0) == Some(index);
387        // A press opens the menu. While it is open, the word is under the press-outside
388        // that closes it, so a second press on it closes it, as a bar's does.
389        let press = self.path.opened(0, index);
390        // Once a menu is open, the pointer moving onto another word opens that one.
391        let hover = (menu.enabled && self.path.is_open() && !open)
392            .then(|| on_path(self.path.opened(0, index)));
393        // And so do the arrows, from a word with the focus (milestone 605).
394        let arrow = |step| {
395            self.path
396                .is_open()
397                .then(|| self.beside(index, step))
398                .flatten()
399                .map(|i| on_path(self.path.opened(0, i)))
400        };
401        let word = on_enter(
402            Box::new(BarButton {
403                label: menu.label.clone(),
404                style: self.style,
405                open,
406                enabled: menu.enabled,
407                message: on_path(press),
408                left: arrow(-1),
409                right: arrow(1),
410            }),
411            hover,
412        );
413        // In a portal open or shut: a portal takes a place in the tree, and one that came and
414        // went with the menu moved the word under it, so that the focus could not find its
415        // way back to the word when its menu closed (milestone 608).
416        let portal = OverlayPortal::new_boxed(word);
417        if !open {
418            return Box::new(portal);
419        }
420        // Across the bar from inside a menu: the menus either side of this one.
421        let across = Across {
422            previous: self
423                .beside(index, -1)
424                .map(|i| on_path(MenuPath::closed().opened(0, i))),
425            next: self
426                .beside(index, 1)
427                .map(|i| on_path(MenuPath::closed().opened(0, i))),
428        };
429        let panel = panel(
430            &menu.entries,
431            1,
432            &self.path,
433            on_path,
434            &across,
435            &self.style.menus,
436        );
437        Box::new(
438            portal
439                .overlay_boxed(panel, Placement::Below)
440                .dismiss(on_path(MenuPath::closed())),
441        )
442    }
443}
444
445/// What the left and right arrows do when they leave a menu for the bar.
446struct Across<Msg> {
447    previous: Option<Msg>,
448    next: Option<Msg>,
449}
450
451/// The panel of the menu open at `level`, with the submenu open in it, if any.
452fn panel<Msg: Clone + 'static>(
453    entries: &[Entry<Msg>],
454    level: usize,
455    path: &MenuPath,
456    on_path: &OnPath<Msg>,
457    across: &Across<Msg>,
458    menus: &crate::MenuTheme,
459) -> Box<dyn Widget<Msg>> {
460    // The first row that can be used takes the focus when the menu opens, so the arrows
461    // work in it from there (milestone 605).
462    let first = entries.iter().position(|entry| match entry {
463        Entry::Item(item) => item.is_action(),
464        Entry::Submenu(sub) => sub.enabled,
465    });
466    // Left leaves a submenu for the row that opened it, and a menu off the bar for the
467    // menu before it.
468    let left = if level > 1 {
469        Some(on_path(path.truncated(level - 1)))
470    } else {
471        across.previous.clone()
472    };
473    // The rows as the popup menu builds them: a submenu's is a row that opens it.
474    let items: Vec<MenuItem<Msg>> = entries
475        .iter()
476        .enumerate()
477        .map(|(index, entry)| {
478            let mut row = match entry {
479                Entry::Item(item) => item.clone_row(),
480                Entry::Submenu(sub) => {
481                    let mut row =
482                        MenuItem::new(sub.label.clone(), on_path(path.opened(level, index)))
483                            .enabled(sub.enabled);
484                    row.submenu = true;
485                    row.expanded = sub.enabled && path.at(level) == Some(index);
486                    row
487                }
488            };
489            // Right opens a submenu from its row, and goes on to the next menu from any
490            // other row.
491            let right = match entry {
492                Entry::Submenu(sub) if sub.enabled => Some(on_path(path.opened(level, index))),
493                _ => across.next.clone(),
494            };
495            row.keys = RowKeys {
496                left: left.clone(),
497                right,
498                // Escape closes this menu, and the ones open from it, and no more.
499                escape: Some(on_path(path.truncated(level - 1))),
500                autofocus: Some(index) == first,
501            };
502            row
503        })
504        .collect();
505    let decorate = |index: usize, row: Box<dyn Widget<Msg>>| -> Box<dyn Widget<Msg>> {
506        match &entries[index] {
507            Entry::Submenu(sub) if sub.enabled => {
508                let open = path.at(level) == Some(index);
509                let hover = (!open).then(|| on_path(path.opened(level, index)));
510                // In a portal open or shut, as the word on the bar is.
511                let portal = OverlayPortal::new_boxed(on_enter(row, hover));
512                if open {
513                    let inner = panel(&sub.entries, level + 1, path, on_path, across, menus);
514                    Box::new(portal.overlay_boxed(inner, Placement::Beside))
515                } else {
516                    Box::new(portal)
517                }
518            }
519            // Any other row of this menu closes a submenu open beside it.
520            _ => {
521                let hover = (path.depth() > level).then(|| on_path(path.truncated(level)));
522                on_enter(row, hover)
523            }
524        }
525    };
526    menu_panel(
527        &items,
528        &RowLook {
529            // What the caller said about the bar's menus; the theme's bar menus, a
530            // desktop's and the application's menus answer the rest, at layout.
531            look: PanelStyle {
532                background: menus.background,
533                shape: menus
534                    .shape
535                    .or(menus.radius.map(frus_core::ShapeBorder::rounded)),
536                elevation: menus.elevation,
537                shadow_color: menus.shadow_color,
538                padding: menus.padding,
539                border: menus.border,
540            },
541            text_style: menus.text_style,
542            item_padding: menus.item_padding,
543            item_height: menus.item_height,
544            divider_height: menus.divider_height,
545            enabled: true,
546            kind: PanelKind::Bar,
547        },
548        &decorate,
549    )
550}
551
552/// `row`, sending `hover` when the pointer comes onto it.
553///
554/// A region may come and go with the menus: it is transparent, and takes no place in the
555/// tree of its own. A portal does, which is why the word and the rows that open submenus
556/// are in theirs open or shut (milestone 608).
557fn on_enter<Msg: Clone + 'static>(
558    row: Box<dyn Widget<Msg>>,
559    hover: Option<Msg>,
560) -> Box<dyn Widget<Msg>> {
561    match hover {
562        Some(message) => Box::new(crate::MouseRegion::new(row).on_enter(move |_| message.clone())),
563        None => row,
564    }
565}
566
567/// A menu's word on the bar.
568struct BarButton<Msg> {
569    label: String,
570    style: BarStyle,
571    open: bool,
572    enabled: bool,
573    message: Msg,
574    /// What the arrows send from this word: the menu beside it, while one is open.
575    left: Option<Msg>,
576    right: Option<Msg>,
577}
578
579/// Room either side of a word on the bar.
580const BAR_PAD_X: f32 = 12.0;
581
582impl<Msg: Clone> Widget<Msg> for BarButton<Msg> {
583    fn style(&self) -> Style {
584        self.sizing(None)
585    }
586
587    fn style_themed(&self, theme: &Theme) -> Style {
588        self.sizing(Some(theme))
589    }
590
591    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
592        &[]
593    }
594
595    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
596        let o = status.opacity;
597        let look = self.style.resolve(theme);
598        // The highlight: under the pointer it fades in, and the word whose menu is open
599        // keeps it, stronger, so the eye can find what the panel belongs to.
600        let hover = if self.enabled {
601            status
602                .hover_progress
603                .max(status.press_progress)
604                .clamp(0.0, 1.0)
605        } else {
606            0.0
607        };
608        let amount = if self.open { 1.0 } else { hover };
609        if amount > 0.0 {
610            let colour = match look.highlight {
611                Some(c) => c.with_alpha(c.a * amount),
612                None => {
613                    let strength = if self.open { 0.12 } else { 0.08 * amount };
614                    if look.background.a < 1.0 {
615                        // A bar that lets what is behind it show — the system's own title
616                        // bar under it (milestone 643): a wash of the words over it, since
617                        // there is no surface of the bar's own to lean.
618                        look.foreground.with_alpha(look.foreground.a * strength)
619                    } else {
620                        // Toward the words: on a bar of the system's accent with white
621                        // words, a highlight toward `on_surface` would darken it
622                        // (milestone 642).
623                        look.background.lerp(look.foreground, strength)
624                    }
625                }
626            };
627            let inset = look.item_inset.min(bounds.height * 0.5);
628            scene.draw_rect(
629                Rect::new(
630                    bounds.x,
631                    bounds.y + inset,
632                    bounds.width,
633                    bounds.height - 2.0 * inset,
634                ),
635                colour.fade(o),
636                look.item_radius,
637                0.0,
638                frus_core::Color::TRANSPARENT,
639            );
640        }
641        let style = look.text_style.resolved();
642        let ink = if self.enabled {
643            look.foreground
644        } else {
645            crate::disabled::disabled_content(theme)
646        };
647        let y = bounds.y + (bounds.height - style.line_height()) * 0.5;
648        scene.text(
649            Point::new(bounds.x + look.item_padding.left, y),
650            self.label.clone(),
651            &style,
652            ink.fade(o),
653        );
654    }
655
656    fn on_click(&self) -> Option<Msg> {
657        self.enabled.then(|| self.message.clone())
658    }
659
660    fn focusable(&self) -> bool {
661        self.enabled
662    }
663
664    fn on_key(&self, key: &crate::interaction::Key) -> crate::interaction::KeyResponse<Msg> {
665        use crate::interaction::{Key, KeyResponse};
666        // A word that cannot be used answers no key, as it answers no press.
667        if !self.enabled {
668            return KeyResponse::Ignored;
669        }
670        let sent = match key {
671            Key::Left { .. } => self.left.clone(),
672            Key::Right { .. } => self.right.clone(),
673            _ => None,
674        };
675        match sent {
676            Some(message) => KeyResponse::Handled(Some(message)),
677            None => KeyResponse::Ignored,
678        }
679    }
680
681    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
682        let semantics =
683            frus_core::SemanticsProperties::new(frus_core::Role::Button).label(self.label.clone());
684        Some(if self.enabled {
685            semantics.clickable()
686        } else {
687            semantics.disabled(true)
688        })
689    }
690
691    fn debug_name(&self) -> &'static str {
692        "MenuBarButton"
693    }
694}
695
696impl<Msg> BarButton<Msg> {
697    fn sizing(&self, theme: Option<&Theme>) -> Style {
698        let fallback = Theme::default();
699        let look = self.style.resolve(theme.unwrap_or(&fallback));
700        let style = look.text_style.resolved();
701        let width = frus_text::measure_resolved(&self.label, &style).width
702            + look.item_padding.left
703            + look.item_padding.right;
704        Style {
705            width: Dimension::Length(width.ceil()),
706            height: Dimension::Length(look.height),
707            ..Style::default()
708        }
709    }
710}
711
712impl<Msg: Clone> Widget<Msg> for MenuBar<Msg> {
713    fn style(&self) -> Style {
714        Widget::<Msg>::style_themed(self, &Theme::default())
715    }
716
717    fn style_themed(&self, theme: &Theme) -> Style {
718        let look = self.style.resolve(theme);
719        Style {
720            flex_direction: FlexDirection::Row,
721            align: Align::Center,
722            height: Dimension::Length(look.height),
723            padding: look.padding,
724            ..Style::default()
725        }
726    }
727
728    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
729        &self.children
730    }
731
732    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
733        let look = self.style.resolve(theme);
734        scene.fill_rect(bounds, look.background.fade(status.opacity));
735    }
736
737    fn on_click(&self) -> Option<Msg> {
738        None
739    }
740
741    /// A bar is a band across the top of the window.
742    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
743        crate::widget::FillAxes {
744            horizontal: true,
745            vertical: false,
746        }
747    }
748
749    fn debug_name(&self) -> &'static str {
750        "MenuBar"
751    }
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757    use crate::{build_ui, Runtime};
758    use frus_core::Size;
759
760    #[derive(Clone, Debug, PartialEq)]
761    enum Msg {
762        Menu(MenuPath),
763        New,
764        Quit,
765        Recent(usize),
766        Wrap,
767    }
768
769    /// File (New, a rule, Open recent ▸ (two files), Quit) and View (Word wrap).
770    fn bar(path: &MenuPath) -> MenuBar<Msg> {
771        MenuBar::new(path, Msg::Menu)
772            .menu(
773                SubmenuButton::new("File")
774                    .item(MenuItem::new("New", Msg::New).shortcut("Ctrl+N"))
775                    .divider()
776                    .submenu(
777                        SubmenuButton::new("Open recent")
778                            .item(MenuItem::new("notes.txt", Msg::Recent(0)))
779                            .item(MenuItem::new("todo.md", Msg::Recent(1))),
780                    )
781                    .item(MenuItem::new("Quit", Msg::Quit)),
782            )
783            .menu(SubmenuButton::new("View").item(MenuItem::checked("Word wrap", true, Msg::Wrap)))
784    }
785
786    const SIZE: Size = Size::new(800.0, 600.0);
787
788    fn ui(root: &MenuBar<Msg>) -> crate::Ui<Msg> {
789        build_ui(root, SIZE, &Runtime::default(), &Theme::light())
790    }
791
792    /// What a press at `(x, y)` sends.
793    fn press(root: &MenuBar<Msg>, x: f32, y: f32) -> Option<Msg> {
794        let ui = ui(root);
795        ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id))
796    }
797
798    /// What the pointer arriving at `(x, y)` sends.
799    fn hover(root: &MenuBar<Msg>, x: f32, y: f32) -> Option<Msg> {
800        let ui = ui(root);
801        ui.hover_regions_at(Point::new(x, y))
802            .into_iter()
803            .find_map(|(id, _, _)| {
804                crate::ui::find_widget(root as &dyn Widget<Msg>, id)?.on_hover_event(
805                    crate::HoverEvent::Enter {
806                        local: Point::new(1.0, 1.0),
807                    },
808                )
809            })
810    }
811
812    /// Where the words `label` were painted, top left.
813    fn text_at(root: &MenuBar<Msg>, label: &str) -> Option<Point> {
814        let ui = ui(root);
815        let found = ui.scene().primitives().iter().find_map(|p| match p {
816            frus_core::Primitive::Text { position, text, .. } if text == label => Some(*position),
817            _ => None,
818        });
819        found
820    }
821
822    fn path(indices: &[usize]) -> MenuPath {
823        MenuPath::from_indices(indices.to_vec())
824    }
825
826    #[test]
827    fn a_path_opens_and_closes_by_level() {
828        let open = MenuPath::closed().opened(0, 2);
829        assert_eq!(open.indices(), &[2]);
830        let deeper = open.opened(1, 3);
831        assert_eq!(deeper.indices(), &[2, 3]);
832        assert_eq!(
833            deeper.opened(0, 1).indices(),
834            &[1],
835            "another menu closes the submenu"
836        );
837        assert_eq!(deeper.truncated(1).indices(), &[2]);
838        assert_eq!(deeper.up().indices(), &[2]);
839        assert!(!deeper.truncated(0).is_open());
840        assert_eq!(deeper.at(1), Some(3));
841        assert_eq!(deeper.depth(), 2);
842    }
843
844    /// **Closed**, the bar shows its words and nothing else; pressing one opens it.
845    #[test]
846    fn pressing_a_word_opens_its_menu() {
847        let closed = bar(&MenuPath::closed());
848        let file = text_at(&closed, "File").expect("the word");
849        assert!(text_at(&closed, "Quit").is_none(), "no menu is showing");
850        assert_eq!(
851            press(&closed, file.x + 5.0, 20.0),
852            Some(Msg::Menu(path(&[0])))
853        );
854        let view = text_at(&closed, "View").expect("the word");
855        assert_eq!(
856            press(&closed, view.x + 5.0, 20.0),
857            Some(Msg::Menu(path(&[1])))
858        );
859        // With nothing open, moving over a word opens nothing.
860        assert_eq!(hover(&closed, view.x + 5.0, 20.0), None);
861    }
862
863    /// **Open**, its rows are under it; a row sends its own message, the word closes the
864    /// menu, and a press anywhere else closes it too.
865    #[test]
866    fn an_open_menu_shows_its_rows_and_closes() {
867        let open = bar(&path(&[0]));
868        let file = text_at(&open, "File").expect("the word");
869        let quit = text_at(&open, "Quit").expect("a row");
870        assert!(quit.y > 48.0, "under the bar: {quit:?}");
871        assert_eq!(press(&open, quit.x + 5.0, quit.y + 5.0), Some(Msg::Quit));
872        assert_eq!(
873            press(&open, file.x + 5.0, 20.0),
874            Some(Msg::Menu(MenuPath::closed()))
875        );
876        assert_eq!(
877            press(&open, 700.0, 500.0),
878            Some(Msg::Menu(MenuPath::closed()))
879        );
880    }
881
882    /// **Once one is open, the pointer moves between menus** with no press.
883    #[test]
884    fn hovering_another_word_switches_menus() {
885        let open = bar(&path(&[0]));
886        let view = text_at(&open, "View").expect("the word");
887        assert_eq!(
888            hover(&open, view.x + 5.0, 20.0),
889            Some(Msg::Menu(path(&[1])))
890        );
891        let file = text_at(&open, "File").expect("the word");
892        assert_eq!(hover(&open, file.x + 5.0, 20.0), None, "already open");
893    }
894
895    /// **A submenu** opens when the pointer comes onto its row, beside it; another row of
896    /// the same menu closes it again.
897    #[test]
898    fn a_submenu_opens_on_hover_beside_its_row() {
899        let open = bar(&path(&[0]));
900        let recent = text_at(&open, "Open recent").expect("the row");
901        assert!(text_at(&open, "notes.txt").is_none());
902        assert_eq!(
903            hover(&open, recent.x + 5.0, recent.y + 5.0),
904            Some(Msg::Menu(path(&[0, 2])))
905        );
906        assert_eq!(
907            press(&open, recent.x + 5.0, recent.y + 5.0),
908            Some(Msg::Menu(path(&[0, 2])))
909        );
910
911        let deeper = bar(&path(&[0, 2]));
912        let row = text_at(&deeper, "Open recent").expect("the row");
913        let notes = text_at(&deeper, "notes.txt").expect("the submenu");
914        assert!(
915            notes.x > row.x + 100.0,
916            "beside the row: {notes:?} vs {row:?}"
917        );
918        assert!(
919            (notes.y - row.y).abs() < 10.0,
920            "level with it: {notes:?} vs {row:?}"
921        );
922        assert_eq!(
923            press(&deeper, notes.x + 5.0, notes.y + 5.0),
924            Some(Msg::Recent(0))
925        );
926        let new = text_at(&deeper, "New").expect("a row");
927        assert_eq!(
928            hover(&deeper, new.x + 5.0, new.y + 5.0),
929            Some(Msg::Menu(path(&[0])))
930        );
931    }
932
933    /// **A disabled menu** is shown and does not open.
934    #[test]
935    fn a_disabled_menu_does_not_open() {
936        let bar = MenuBar::new(&MenuPath::closed(), Msg::Menu).menu(
937            SubmenuButton::new("Edit")
938                .item(MenuItem::new("New", Msg::New))
939                .enabled(false),
940        );
941        let edit = text_at(&bar, "Edit").expect("still shown");
942        assert_eq!(press(&bar, edit.x + 5.0, 20.0), None);
943        // Even a path that names it does not open it.
944        let named = MenuBar::new(&path(&[0]), Msg::Menu).menu(
945            SubmenuButton::new("Edit")
946                .item(MenuItem::new("New", Msg::New))
947                .enabled(false),
948        );
949        assert!(text_at(&named, "New").is_none());
950    }
951
952    /// **Near the window's end edge**, a submenu with no room beside its row opens on the
953    /// other side instead of over the row.
954    #[test]
955    fn a_submenu_with_no_room_opens_on_the_other_side() {
956        let crowded = |path: &MenuPath| {
957            let filler = |word: &str| SubmenuButton::new(word).item(MenuItem::new("x", Msg::New));
958            MenuBar::new(path, Msg::Menu)
959                .menu(filler("Alpha"))
960                .menu(filler("Bravo"))
961                .menu(filler("Charlie"))
962                .menu(filler("Delta"))
963                .menu(
964                    SubmenuButton::new("File").submenu(
965                        SubmenuButton::new("Open recent")
966                            .item(MenuItem::new("notes.txt", Msg::Recent(0))),
967                    ),
968                )
969        };
970        let root = crowded(&path(&[4, 0]));
971        let ui = build_ui(
972            &root,
973            Size::new(560.0, 600.0),
974            &Runtime::default(),
975            &Theme::light(),
976        );
977        let at = |label: &str| {
978            ui.scene()
979                .primitives()
980                .iter()
981                .find_map(|p| match p {
982                    frus_core::Primitive::Text { position, text, .. } if text == label => {
983                        Some(*position)
984                    }
985                    _ => None,
986                })
987                .expect(label)
988        };
989        let (row, notes) = (at("Open recent"), at("notes.txt"));
990        assert!(notes.x < row.x, "on the start side: {notes:?} vs {row:?}");
991        assert!(notes.x >= 0.0, "inside the window: {notes:?}");
992    }
993
994    /// The identity of the row whose words are `label`.
995    fn row_id(root: &MenuBar<Msg>, label: &str) -> crate::interaction::WidgetId {
996        let at = text_at(root, label).expect(label);
997        ui(root)
998            .hit(Point::new(at.x + 5.0, at.y + 5.0))
999            .expect("a row")
1000    }
1001
1002    /// What `key` sends from the row whose words are `label`.
1003    fn key_on(root: &MenuBar<Msg>, label: &str, key: crate::interaction::Key) -> Option<Msg> {
1004        let id = row_id(root, label);
1005        match crate::ui::find_widget(root as &dyn Widget<Msg>, id)?.on_key(&key) {
1006            crate::interaction::KeyResponse::Handled(message) => message,
1007            _ => None,
1008        }
1009    }
1010
1011    /// What Escape sends from the row `label`, bubbling up from it as the shell does.
1012    fn escape_from(root: &MenuBar<Msg>, label: &str) -> Option<Msg> {
1013        let id = row_id(root, label);
1014        let path = crate::ui::find_path(root as &dyn Widget<Msg>, id);
1015        path.iter()
1016            .rev()
1017            .find_map(|w| match w.on_key(&crate::interaction::Key::Escape) {
1018                crate::interaction::KeyResponse::Handled(message) => Some(message),
1019                _ => None,
1020            })
1021            .flatten()
1022    }
1023
1024    const LEFT: crate::interaction::Key = crate::interaction::Key::Left {
1025        shift: false,
1026        word: false,
1027    };
1028    const RIGHT: crate::interaction::Key = crate::interaction::Key::Right {
1029        shift: false,
1030        word: false,
1031    };
1032
1033    /// **The first row that can be used takes the focus** when its menu opens: not a rule,
1034    /// not a disabled row.
1035    #[test]
1036    fn the_first_usable_row_takes_the_focus() {
1037        let open = bar(&path(&[0]));
1038        let focus: Vec<_> = ui(&open).autofocus_ids().collect();
1039        assert_eq!(focus, vec![row_id(&open, "New")]);
1040
1041        let skipping = MenuBar::new(&path(&[0]), Msg::Menu).menu(
1042            SubmenuButton::new("File")
1043                .divider()
1044                .item(MenuItem::new("Gone", Msg::New).enabled(false))
1045                .item(MenuItem::new("Quit", Msg::Quit)),
1046        );
1047        let focus: Vec<_> = ui(&skipping).autofocus_ids().collect();
1048        assert_eq!(focus, vec![row_id(&skipping, "Quit")]);
1049
1050        let deeper = bar(&path(&[0, 2]));
1051        let focus: Vec<_> = ui(&deeper).autofocus_ids().collect();
1052        assert!(
1053            focus.contains(&row_id(&deeper, "notes.txt")),
1054            "the submenu's too"
1055        );
1056    }
1057
1058    /// **Right** opens a submenu from its row and goes on to the next menu from another;
1059    /// **left** goes back from a submenu, and to the menu before from a menu off the bar,
1060    /// round the ends.
1061    #[test]
1062    fn the_arrows_move_through_the_menus() {
1063        let open = bar(&path(&[0]));
1064        assert_eq!(
1065            key_on(&open, "Open recent", RIGHT),
1066            Some(Msg::Menu(path(&[0, 2])))
1067        );
1068        assert_eq!(key_on(&open, "New", RIGHT), Some(Msg::Menu(path(&[1]))));
1069        assert_eq!(
1070            key_on(&open, "New", LEFT),
1071            Some(Msg::Menu(path(&[1]))),
1072            "round the end"
1073        );
1074
1075        let deeper = bar(&path(&[0, 2]));
1076        assert_eq!(
1077            key_on(&deeper, "notes.txt", LEFT),
1078            Some(Msg::Menu(path(&[0])))
1079        );
1080        assert_eq!(
1081            key_on(&deeper, "notes.txt", RIGHT),
1082            Some(Msg::Menu(path(&[1])))
1083        );
1084    }
1085
1086    /// **From a word on the bar**, the arrows move to the menu beside it while one is open,
1087    /// and leave the focus to move as it does anywhere else while none is.
1088    #[test]
1089    fn the_arrows_on_the_bar_switch_menus_only_while_one_is_open() {
1090        let sent = |w: &dyn Widget<Msg>, key| match w.on_key(&key) {
1091            crate::interaction::KeyResponse::Handled(m) => m,
1092            _ => None,
1093        };
1094        // Open, the word is the anchor of the menu floating under it.
1095        let open = bar(&path(&[0]));
1096        let word = open.children()[0].children()[0].as_ref();
1097        assert_eq!(sent(word, RIGHT), Some(Msg::Menu(path(&[1]))));
1098        assert_eq!(
1099            sent(word, LEFT),
1100            Some(Msg::Menu(path(&[1]))),
1101            "round the end"
1102        );
1103
1104        let closed = bar(&MenuPath::closed());
1105        assert_eq!(sent(closed.children()[0].as_ref(), RIGHT), None);
1106    }
1107
1108    /// **Escape closes one level**: from a submenu, the submenu; from a menu, the menu.
1109    #[test]
1110    fn escape_closes_one_level() {
1111        let deeper = bar(&path(&[0, 2]));
1112        assert_eq!(
1113            escape_from(&deeper, "notes.txt"),
1114            Some(Msg::Menu(path(&[0])))
1115        );
1116        let open = bar(&path(&[0]));
1117        assert_eq!(
1118            escape_from(&open, "Quit"),
1119            Some(Msg::Menu(MenuPath::closed()))
1120        );
1121    }
1122
1123    /// **A row whose submenu is shut is as wide as the others too**: it sits in the same
1124    /// portal open or shut, so that it keeps its identity (milestone 608), and a portal with
1125    /// nothing floating is laid out as any container, which must not shrink it either.
1126    #[test]
1127    fn a_row_with_its_submenu_shut_keeps_the_menus_width() {
1128        let open = bar(&path(&[0]));
1129        let recent = text_at(&open, "Open recent").expect("the row");
1130        assert_eq!(
1131            press(&open, recent.x + 120.0, recent.y + 5.0),
1132            Some(Msg::Menu(path(&[0, 2])))
1133        );
1134    }
1135
1136    /// **A row with its submenu open is as wide as the others**, and its chevron is inside
1137    /// the menu. The open row is wrapped in the portal that floats the submenu, and the
1138    /// portal laid its anchor out at its label's width: the chevron was drawn off the menu's
1139    /// start edge, and a press near the row's end missed it (milestone 607).
1140    #[test]
1141    fn a_row_with_its_submenu_open_keeps_the_menus_width() {
1142        let deeper = bar(&path(&[0, 2]));
1143        let ui = ui(&deeper);
1144        let recent = text_at(&deeper, "Open recent").expect("the row");
1145        let quit = text_at(&deeper, "Quit").expect("another row");
1146        let id_at = |x: f32, y: f32| ui.hit(Point::new(x, y + 5.0));
1147        // Near the end of the menu, level with each row.
1148        let end = recent.x + 120.0;
1149        assert_eq!(
1150            press(&deeper, end, recent.y + 5.0),
1151            Some(Msg::Menu(path(&[0, 2])))
1152        );
1153        assert!(id_at(end, quit.y).is_some(), "the other row reaches as far");
1154        // The chevron: a path level with the row, past its label, inside the window.
1155        let chevron = ui
1156            .scene()
1157            .primitives()
1158            .iter()
1159            .find_map(|p| match p {
1160                p @ frus_core::Primitive::Path { .. } => {
1161                    let b = p.bounds();
1162                    ((b.y - recent.y).abs() < 16.0).then_some(b)
1163                }
1164                _ => None,
1165            })
1166            .expect("a chevron on the row");
1167        assert!(chevron.x > recent.x + 60.0, "at the row's end: {chevron:?}");
1168    }
1169
1170    /// The scene of `root` under `theme`.
1171    fn scene_in(root: &MenuBar<Msg>, theme: &Theme) -> Vec<frus_core::Primitive> {
1172        build_ui(root, SIZE, &Runtime::default(), theme)
1173            .scene()
1174            .primitives()
1175            .to_vec()
1176    }
1177
1178    /// The flat rectangles drawn at `(x, y)`, innermost last.
1179    fn rects_at(scene: &[frus_core::Primitive], x: f32, y: f32) -> Vec<(Rect, Color, f32)> {
1180        scene
1181            .iter()
1182            .filter_map(|p| match p {
1183                frus_core::Primitive::Rect {
1184                    rect,
1185                    color,
1186                    radius,
1187                    ..
1188                } if rect.contains(Point::new(x, y)) => Some((*rect, *color, radius.top_left)),
1189                _ => None,
1190            })
1191            .collect()
1192    }
1193
1194    /// The words `label`: where, how big, in what colour.
1195    fn word(scene: &[frus_core::Primitive], label: &str) -> (Point, f32, Color) {
1196        scene
1197            .iter()
1198            .find_map(|p| match p {
1199                frus_core::Primitive::Text {
1200                    position,
1201                    text,
1202                    size,
1203                    color,
1204                    ..
1205                } if text == label => Some((*position, *size, *color)),
1206                _ => None,
1207            })
1208            .expect("the words are painted")
1209    }
1210
1211    fn on(platform: frus_core::TargetPlatform) -> Theme {
1212        Theme::light().with_platform(platform)
1213    }
1214
1215    /// **A desktop's bar is a desktop's**: 30 high, in 13 px words with 8 either side and
1216    /// 4 at the bar's start. A phone's keeps a finger's 48 and its label type.
1217    #[test]
1218    fn a_desktop_bar_is_a_desktops_and_a_phones_a_phones() {
1219        let closed = bar(&MenuPath::closed());
1220        let desk = scene_in(&closed, &on(frus_core::TargetPlatform::Windows));
1221        let surface = rects_at(&desk, 400.0, 2.0);
1222        assert_eq!(surface[0].0.height, 30.0, "the bar");
1223        let (file, size, _) = word(&desk, "File");
1224        assert_eq!(size, 13.0);
1225        assert_eq!(file.x, 4.0 + 8.0, "the bar's room, then the word's");
1226        let (view, _, _) = word(&desk, "View");
1227        let file_width = frus_text::measure_resolved("File", &TextStyle::new(13.0).resolved())
1228            .width
1229            .ceil();
1230        assert_eq!(
1231            view.x,
1232            file.x + file_width + 16.0,
1233            "8 after one, 8 before the next"
1234        );
1235
1236        let phone = scene_in(&closed, &on(frus_core::TargetPlatform::Android));
1237        let surface = rects_at(&phone, 400.0, 2.0);
1238        assert_eq!(surface[0].0.height, crate::theme::MIN_TAP_TARGET);
1239        let (file, size, _) = word(&phone, "File");
1240        assert_eq!(size, Theme::light().text.label_large.resolved().size);
1241        assert_eq!(file.x, BAR_PAD_X);
1242    }
1243
1244    /// **The open word keeps a highlight**, rounded and inside the bar, so the eye finds
1245    /// what the menu belongs to; a word at rest has none. The row whose submenu is open
1246    /// keeps its own, the panel's hover.
1247    #[test]
1248    fn the_open_word_and_the_open_row_keep_their_highlight() {
1249        let theme = on(frus_core::TargetPlatform::Linux);
1250        let open = scene_in(&bar(&path(&[0])), &theme);
1251        let (file, _, _) = word(&open, "File");
1252        let under = rects_at(&open, file.x + 2.0, 15.0);
1253        let (rect, colour, radius) = *under.last().expect("a highlight");
1254        assert_eq!((rect.y, rect.height, radius), (3.0, 24.0, 4.0));
1255        let surface = theme.scheme.surface_container;
1256        assert_eq!(colour, surface.lerp(theme.scheme.on_surface, 0.12));
1257        let (view, _, _) = word(&open, "View");
1258        assert_eq!(
1259            rects_at(&open, view.x + 2.0, 15.0).len(),
1260            1,
1261            "the bar alone under a word at rest"
1262        );
1263
1264        let deeper = scene_in(&bar(&path(&[0, 2])), &theme);
1265        let (row, _, _) = word(&deeper, "Open recent");
1266        let (quit, _, _) = word(&deeper, "Quit");
1267        let lit = rects_at(&deeper, row.x, row.y + 4.0);
1268        let plain = rects_at(&deeper, quit.x, quit.y + 4.0);
1269        assert_eq!(
1270            lit.len(),
1271            plain.len() + 1,
1272            "the open row is lit, the others are not"
1273        );
1274    }
1275
1276    /// **The highlight leans toward the bar's words** (milestone 642): white words on a dark
1277    /// accent bar — a title bar in the system's accent colour — get a lighter highlight, not
1278    /// one toward `on_surface`, which would darken it.
1279    #[test]
1280    fn the_highlight_leans_toward_the_words() {
1281        let theme = on(frus_core::TargetPlatform::Linux);
1282        let accent = Color::rgb(0.0, 0.2, 0.5);
1283        let open = scene_in(
1284            &bar(&path(&[0]))
1285                .background(accent)
1286                .foreground_color(Color::WHITE),
1287            &theme,
1288        );
1289        let (file, _, _) = word(&open, "File");
1290        let (_, colour, _) = *rects_at(&open, file.x + 2.0, 15.0)
1291            .last()
1292            .expect("a highlight");
1293        assert_eq!(colour, accent.lerp(Color::WHITE, 0.12));
1294    }
1295
1296    /// **A bar that lets what is behind it show** (milestone 643) — the system's own title
1297    /// bar under it — has no surface to lean its highlight on: the highlight is a wash of its
1298    /// words.
1299    #[test]
1300    fn a_see_through_bar_s_highlight_is_a_wash_of_its_words() {
1301        let theme = on(frus_core::TargetPlatform::Linux);
1302        let open = scene_in(
1303            &bar(&path(&[0]))
1304                .background(Color::TRANSPARENT)
1305                .foreground_color(Color::WHITE),
1306            &theme,
1307        );
1308        let (file, _, _) = word(&open, "File");
1309        let (_, colour, _) = *rects_at(&open, file.x + 2.0, 15.0)
1310            .iter()
1311            .rev()
1312            .find(|(_, colour, _)| colour.a > 0.0)
1313            .expect("a highlight");
1314        assert_eq!(colour, Color::WHITE.with_alpha(0.12));
1315    }
1316
1317    /// **Every part of the bar is the caller's to say**: its height, surface, room,
1318    /// type, ink, highlight and the menus it opens.
1319    #[test]
1320    fn every_part_of_the_bar_can_be_said() {
1321        let red = Color::rgb(0.8, 0.1, 0.1);
1322        let green = Color::rgb(0.1, 0.7, 0.2);
1323        let blue = Color::rgb(0.1, 0.2, 0.9);
1324        let edge = frus_core::BorderSide {
1325            color: green,
1326            width: 2.0,
1327        };
1328        // Said after the menus: the words are built again.
1329        let styled = bar(&path(&[0]))
1330            .height(40.0)
1331            .background(red)
1332            .padding(Insets::new(0.0, 10.0, 0.0, 10.0))
1333            .item_padding(Insets::new(0.0, 20.0, 0.0, 20.0))
1334            .text_style(TextStyle::new(16.0))
1335            .foreground_color(green)
1336            .highlight_color(blue)
1337            .item_radius(0.0)
1338            .item_inset(0.0)
1339            .menu_style(crate::MenuTheme {
1340                item_height: Some(32.0),
1341                border: Some(edge),
1342                ..Default::default()
1343            });
1344        let scene = scene_in(&styled, &on(frus_core::TargetPlatform::Linux));
1345        let surface = rects_at(&scene, 400.0, 2.0);
1346        assert_eq!((surface[0].0.height, surface[0].1), (40.0, red));
1347        let (file, size, ink) = word(&scene, "File");
1348        assert_eq!((file.x, size, ink), (30.0, 16.0, green));
1349        let (rect, colour, radius) = *rects_at(&scene, file.x, 20.0).last().expect("lit");
1350        assert_eq!(
1351            (rect.y, rect.height, colour, radius),
1352            (0.0, 40.0, blue, 0.0)
1353        );
1354        let (new, _, _) = word(&scene, "New");
1355        let (quit, _, _) = word(&scene, "Quit");
1356        assert!(scene.iter().any(|p| matches!(
1357            p,
1358            frus_core::Primitive::Rect { border_width, border_color, .. }
1359                if *border_width == 2.0 && *border_color == green
1360        )));
1361        // New, a rule, Open recent, Quit: three rows of 32 and a rule apart.
1362        // Said last, a word still reaches the words already built.
1363        let last = bar(&MenuPath::closed()).text_style(TextStyle::new(18.0));
1364        let scene_last = scene_in(&last, &on(frus_core::TargetPlatform::Linux));
1365        assert_eq!(word(&scene_last, "File").1, 18.0);
1366        let rule = quit.y - new.y - 2.0 * 32.0;
1367        assert_eq!(
1368            rule,
1369            crate::MenuTheme::desktop(&Theme::light())
1370                .divider_height
1371                .unwrap()
1372        );
1373    }
1374
1375    /// **The theme says what the caller did not**, and the caller outranks it.
1376    #[test]
1377    fn the_theme_answers_what_the_caller_left_unsaid() {
1378        let mut theme = on(frus_core::TargetPlatform::Linux);
1379        theme.widgets.menu_bar = crate::MenuBarTheme {
1380            height: Some(36.0),
1381            foreground: Some(Color::rgb(0.5, 0.0, 0.5)),
1382            menus: crate::MenuTheme {
1383                item_height: Some(28.0),
1384                ..Default::default()
1385            },
1386            ..Default::default()
1387        };
1388        let scene = scene_in(&bar(&path(&[0])), &theme);
1389        assert_eq!(rects_at(&scene, 400.0, 2.0)[0].0.height, 36.0);
1390        assert_eq!(word(&scene, "File").2, Color::rgb(0.5, 0.0, 0.5));
1391        let (new, _, _) = word(&scene, "New");
1392        let (rule_after, _, _) = word(&scene, "Open recent");
1393        let (quit, _, _) = word(&scene, "Quit");
1394        assert_eq!(quit.y - rule_after.y, 28.0, "the theme's rows");
1395        assert!(rule_after.y > new.y);
1396
1397        let said = bar(&path(&[0])).height(44.0);
1398        let scene = scene_in(&said, &theme);
1399        assert_eq!(rects_at(&scene, 400.0, 2.0)[0].0.height, 44.0);
1400    }
1401
1402    /// **A desktop's menus are a desktop's**: 26 px rows in 13 px type, ringed by a
1403    /// hairline, with rules that are a line and room. A phone's keep a finger's rows.
1404    #[test]
1405    fn a_desktop_bars_menus_are_a_desktops() {
1406        let open = bar(&path(&[0]));
1407        let theme = on(frus_core::TargetPlatform::MacOs);
1408        let scene = scene_in(&open, &theme);
1409        let (row, size, _) = word(&scene, "Open recent");
1410        let (quit, _, _) = word(&scene, "Quit");
1411        assert_eq!((quit.y - row.y, size), (26.0, 13.0));
1412        assert!(scene.iter().any(|p| matches!(
1413            p,
1414            frus_core::Primitive::Rect { border_width, border_color, .. }
1415                if *border_width == 1.0 && *border_color == theme.scheme.outline_variant
1416        )));
1417        let (new, _, _) = word(&scene, "New");
1418        let line = scene.iter().find_map(|p| match p {
1419            frus_core::Primitive::Rect { rect, .. }
1420                if rect.height == 1.0 && rect.y > new.y && rect.y < row.y =>
1421            {
1422                Some(*rect)
1423            }
1424            _ => None,
1425        });
1426        assert!(
1427            line.is_some_and(|l| l.width > 100.0),
1428            "the rule is a line across the menu"
1429        );
1430
1431        let phone = scene_in(&open, &on(frus_core::TargetPlatform::Android));
1432        let (row, _, _) = word(&phone, "Open recent");
1433        let (quit, _, _) = word(&phone, "Quit");
1434        assert!(quit.y - row.y >= crate::theme::MIN_TAP_TARGET);
1435    }
1436}