Skip to main content

improv_gui/
menu.rs

1//! The NeXTSTEP application menu (spec §4.1): a vertical floating panel with a
2//! black title bar and beveled items, whose submenus cascade to the right.
3//!
4//! The menu knows nothing about the app. [`show`] draws it and returns the
5//! [`Command`] the user picked, and the caller runs it. [`key_command`] maps the
6//! key equivalents (Ctrl on Linux and Windows, Cmd on macOS) to the same
7//! commands. Everything is painted with painter shapes and text.
8
9use egui::{
10    pos2, vec2, Align2, Area, Color32, Context, FontFamily, FontId, Id, Key, LayerId, Order,
11    Painter, Pos2, Rect, Response, Sense, Shape, Stroke, Vec2,
12};
13
14use crate::theme::{BLACK, BOLD, DARK_GRAY, LIGHT_GRAY, WHITE};
15
16/// Something the user can ask for from the menu or a key equivalent.
17#[derive(Clone, Copy, PartialEq, Eq, Debug)]
18pub enum Command {
19    InfoPanel,
20    /// Show or hide the model browser window.
21    ModelBrowser,
22    SaveModel,
23    /// Reload the model from the store, dropping in-memory state (the way
24    /// out of a save conflict: another writer changed the model).
25    RevertToSaved,
26    Undo,
27    Redo,
28    PivotRotate,
29    AddMatrix,
30    RemoveMatrix,
31    SaveView,
32    /// Open the n-th entry of the `views` list passed to [`show`].
33    OpenView(usize),
34    ToggleChart,
35    ToggleCsv,
36    /// Show or hide the function help browser (also F1).
37    FunctionHelp,
38    Hide,
39    Quit,
40}
41
42/// A submenu row: label, key equivalent, command. `None` is not built yet and
43/// draws disabled.
44type Leaf = (&'static str, Option<char>, Option<Command>);
45
46/// What a top-level item does.
47enum Top {
48    /// Opens a submenu. An empty one draws disabled.
49    Sub(&'static [Leaf]),
50    /// Opens the View submenu: Model Browser, then the views the caller
51    /// passes to [`show`].
52    Views,
53    /// Runs a command directly (`None`: disabled).
54    Leaf(Option<Command>),
55}
56
57use Command as C;
58
59/// The menu tree. Top level per spec §4.1; submenus hold the commands the app
60/// can already run, plus disabled placeholders for File.
61const MENU: &[(&str, Option<char>, Top)] = &[
62    (
63        "Info",
64        None,
65        Top::Sub(&[("Info Panel…", None, Some(C::InfoPanel))]),
66    ),
67    (
68        "File",
69        None,
70        Top::Sub(&[
71            ("New", None, None),
72            ("Open…", None, None),
73            ("Save", Some('s'), Some(C::SaveModel)),
74            ("Save As…", None, None),
75            ("Revert to Saved", None, Some(C::RevertToSaved)),
76            ("Import/Export CSV…", None, Some(C::ToggleCsv)),
77            ("Close", None, None),
78        ]),
79    ),
80    (
81        "Edit",
82        None,
83        Top::Sub(&[
84            ("Undo", Some('z'), Some(C::Undo)),
85            ("Redo", Some('Z'), Some(C::Redo)),
86        ]),
87    ),
88    ("Format", None, Top::Sub(&[])),
89    (
90        "Worksheet",
91        None,
92        Top::Sub(&[
93            ("Pivot", None, Some(C::PivotRotate)),
94            ("Add Matrix", None, Some(C::AddMatrix)),
95            ("Remove Matrix", None, Some(C::RemoveMatrix)),
96            ("Save View", None, Some(C::SaveView)),
97        ]),
98    ),
99    ("View", None, Top::Views),
100    (
101        "Graph",
102        None,
103        Top::Sub(&[("Show Chart", None, Some(C::ToggleChart))]),
104    ),
105    ("Print…", Some('p'), Top::Leaf(None)),
106    ("Services", None, Top::Sub(&[])),
107    (
108        "Help",
109        None,
110        Top::Sub(&[("Functions…", None, Some(C::FunctionHelp))]),
111    ),
112    ("Hide", Some('h'), Top::Leaf(Some(C::Hide))),
113    ("Quit", Some('q'), Top::Leaf(Some(C::Quit))),
114];
115
116const TITLE: &str = "Improv";
117const NO_VIEWS: &str = "No Views";
118const MODEL_BROWSER: &str = "Model Browser";
119
120// Geometry, in points (spec §3, §4.1).
121const BORDER: f32 = 1.0;
122const TITLE_HEIGHT: f32 = 23.0;
123const ITEM_HEIGHT: f32 = 20.0;
124const PAD: f32 = 6.0;
125/// Room between a label and the right-hand marker, plus the marker itself.
126const RIGHT_SLOT: f32 = 28.0;
127const MIN_WIDTH: f32 = 96.0;
128/// Room the main menu takes from the workspace's left edge: its ~110 px panel
129/// plus its margin. A fresh worksheet window opens clear of it.
130pub(crate) const MENU_W: f32 = 120.0;
131const FONT_SIZE: f32 = 12.0;
132
133/// Where the menu sits and which submenu is open. Lives on the app.
134#[derive(Clone, Copy, Debug, Default)]
135pub struct MenuState {
136    /// Index into the top level of the open submenu.
137    open: Option<usize>,
138    /// Top-left corner of the menu; dragging the title bar moves it. `None`
139    /// until first shown, which places it at [`show`]'s `home`.
140    pos: Option<Pos2>,
141}
142
143/// Map a key equivalent (pressed with Ctrl/Cmd) to its command, read off the
144/// [`MENU`] tree so the two can't disagree: a lowercase letter is the plain
145/// chord, an uppercase one the chord with Shift. Y is a Redo alias.
146pub fn key_command(key: Key, shift: bool) -> Option<Command> {
147    if key == Key::Y {
148        return Some(C::Redo);
149    }
150    keyed_items()
151        .find(|(k, _)| Key::from_name(&k.to_string()) == Some(key) && k.is_uppercase() == shift)
152        .and_then(|(_, c)| c)
153}
154
155/// Every menu item with a key equivalent, top level and submenus.
156fn keyed_items() -> impl Iterator<Item = (char, Option<Command>)> {
157    MENU.iter().flat_map(|(_, key, top)| {
158        let leaves: &[Leaf] = match top {
159            Top::Sub(l) => l,
160            _ => &[],
161        };
162        let top = match top {
163            Top::Leaf(c) => key.map(|k| (k, *c)),
164            _ => None,
165        };
166        top.into_iter()
167            .chain(leaves.iter().filter_map(|&(_, k, c)| k.map(|k| (k, c))))
168    })
169}
170
171/// Draw the menu and return the command picked this frame, if any. `views`
172/// fills the View submenu as `(name, is_current)`; picking the n-th returns
173/// [`Command::OpenView`]`(n)`. `enabled` greys out commands that can't run now.
174/// The menu's top-left starts at `home`; after that it stays where it's dragged.
175pub fn show(
176    ctx: &Context,
177    state: &mut MenuState,
178    home: Pos2,
179    views: &[(String, bool)],
180    enabled: impl Fn(Command) -> bool,
181) -> Option<Command> {
182    let id = menu_id();
183    let mut pos = state.pos.unwrap_or(home);
184    // Apply this frame's title drag before placing the menu, so it tracks the
185    // pointer with no frame delay (as nxwin does).
186    if let Some(r) = ctx.read_response(id.with("title")) {
187        pos += r.drag_delta();
188    }
189    let screen = ctx.screen_rect();
190    let pos = pos.clamp(
191        screen.min,
192        (screen.max - vec2(MIN_WIDTH, TITLE_HEIGHT)).max(screen.min),
193    );
194    state.pos = Some(pos);
195
196    // A press anywhere but the menu or its open submenu closes the submenu.
197    let pressed_at = ctx.input(|i| i.pointer.interact_pos().filter(|_| i.pointer.any_pressed()));
198    if let Some(p) = pressed_at {
199        let layer = ctx.layer_id_at(p);
200        let ours = |id| layer == Some(LayerId::new(Order::Foreground, id));
201        if !ours(id) && !state.open.is_some_and(|i| ours(sub_id(i))) {
202            state.open = None;
203        }
204    }
205    if ctx.input(|i| i.key_pressed(Key::Escape)) {
206        state.open = None;
207    }
208
209    let rows: Vec<Row> = MENU
210        .iter()
211        .enumerate()
212        .map(|(i, (label, key, top))| Row {
213            label,
214            key: *key,
215            sub: !matches!(top, Top::Leaf(_)),
216            check: false,
217            enabled: match top {
218                Top::Sub(items) => !items.is_empty(),
219                Top::Views => true,
220                Top::Leaf(c) => c.is_some_and(&enabled),
221            },
222            open: state.open == Some(i),
223        })
224        .collect();
225    let (menu_rect, items) = panel(ctx, id, pos, TITLE, &rows, Sense::drag());
226
227    let mut picked = None;
228    for (i, r) in items.iter().enumerate() {
229        if !(r.clicked() && rows[i].enabled) {
230            continue;
231        }
232        match MENU[i].2 {
233            Top::Leaf(c) => {
234                picked = c;
235                state.open = None;
236            }
237            _ => state.open = (state.open != Some(i)).then_some(i),
238        }
239    }
240
241    if let Some(i) = state.open {
242        let leaves: Vec<(&str, Option<char>, Option<Command>, bool)> = match MENU[i].2 {
243            Top::Sub(l) => l.iter().map(|&(a, k, c)| (a, k, c, false)).collect(),
244            Top::Views => {
245                let browser = (MODEL_BROWSER, None, Some(C::ModelBrowser), false);
246                let list = views
247                    .iter()
248                    .enumerate()
249                    .map(|(n, (name, cur))| (name.as_str(), None, Some(C::OpenView(n)), *cur));
250                if views.is_empty() {
251                    vec![browser, (NO_VIEWS, None, None, false)]
252                } else {
253                    std::iter::once(browser).chain(list).collect()
254                }
255            }
256            Top::Leaf(_) => Vec::new(),
257        };
258        let rows: Vec<Row> = leaves
259            .iter()
260            .map(|&(label, key, c, check)| Row {
261                label,
262                key,
263                sub: false,
264                check,
265                enabled: c.is_some_and(&enabled),
266                open: false,
267            })
268            .collect();
269        // Cascade from the right edge, title level with the item; from the
270        // left edge when that runs off screen, and lifted to stay on it.
271        let size = panel_size(ctx, MENU[i].0, &rows);
272        let x = if menu_rect.right() + size.x > screen.right() {
273            menu_rect.left() - size.x
274        } else {
275            menu_rect.right()
276        };
277        let y = (items[i].rect.top() - BORDER)
278            .min(screen.bottom() - size.y)
279            .max(screen.top());
280        let at = pos2(x, y);
281        let (_, sub) = panel(ctx, sub_id(i), at, MENU[i].0, &rows, Sense::hover());
282        for (n, r) in sub.iter().enumerate() {
283            if r.clicked() && rows[n].enabled {
284                picked = leaves[n].2;
285                state.open = None;
286            }
287        }
288    }
289    picked
290}
291
292/// The menu panel's area id.
293pub fn menu_id() -> Id {
294    Id::new("improv_menu")
295}
296
297fn sub_id(i: usize) -> Id {
298    menu_id().with(("sub", i))
299}
300
301/// One row of a menu panel, as drawn.
302struct Row<'a> {
303    label: &'a str,
304    key: Option<char>,
305    /// Draw the submenu arrow.
306    sub: bool,
307    /// Draw a check mark (the current view).
308    check: bool,
309    enabled: bool,
310    /// Its submenu is open: white face.
311    open: bool,
312}
313
314fn font() -> FontId {
315    FontId::proportional(FONT_SIZE)
316}
317
318fn bold() -> FontId {
319    FontId::new(FONT_SIZE, FontFamily::Name(BOLD.into()))
320}
321
322/// Outer size of a menu panel titled `title` holding `rows`.
323fn panel_size(ctx: &Context, title: &str, rows: &[Row]) -> Vec2 {
324    let text_width = |s: &str, f: FontId| {
325        ctx.fonts(|fonts| fonts.layout_no_wrap(s.to_owned(), f, BLACK).size().x)
326    };
327    let width = rows
328        .iter()
329        .map(|r| text_width(r.label, font()) + RIGHT_SLOT)
330        .chain([text_width(title, bold())])
331        .fold(MIN_WIDTH, f32::max)
332        + 2.0 * (PAD + BORDER);
333    let height = 2.0 * BORDER + TITLE_HEIGHT + ITEM_HEIGHT * rows.len() as f32;
334    vec2(width.ceil(), height)
335}
336
337/// Draw one menu panel (title bar plus rows) in its own foreground [`Area`] at
338/// `pos`. Returns the panel's outer rect and each row's response.
339fn panel(
340    ctx: &Context,
341    id: Id,
342    pos: Pos2,
343    title: &str,
344    rows: &[Row],
345    title_sense: Sense,
346) -> (Rect, Vec<Response>) {
347    let outer = Rect::from_min_size(pos, panel_size(ctx, title, rows));
348    let inner = outer.shrink(BORDER);
349
350    Area::new(id)
351        .order(Order::Foreground)
352        .fixed_pos(pos)
353        .constrain(false)
354        .fade_in(false)
355        .show(ctx, |ui| {
356            let p = ui.painter();
357            let title_rect = Rect::from_min_size(inner.min, vec2(inner.width(), TITLE_HEIGHT));
358            p.rect_filled(title_rect, 0.0, BLACK);
359            p.text(
360                pos2(title_rect.left() + PAD, title_rect.center().y),
361                Align2::LEFT_CENTER,
362                title,
363                bold(),
364                WHITE,
365            );
366            let _ = ui.interact(title_rect, id.with("title"), title_sense);
367
368            let responses = rows
369                .iter()
370                .enumerate()
371                .map(|(i, row)| {
372                    let top = title_rect.bottom() + ITEM_HEIGHT * i as f32;
373                    let r = Rect::from_min_size(
374                        pos2(inner.left(), top),
375                        vec2(inner.width(), ITEM_HEIGHT),
376                    );
377                    let resp = ui.interact(r, id.with(i), Sense::click());
378                    let lit = row.open || (row.enabled && resp.is_pointer_button_down_on());
379                    paint_row(p, r, row, if lit { WHITE } else { LIGHT_GRAY });
380                    resp
381                })
382                .collect();
383
384            p.rect_stroke(outer.shrink(BORDER / 2.0), 0.0, Stroke::new(BORDER, BLACK));
385            ui.advance_cursor_after_rect(outer);
386            (outer, responses)
387        })
388        .inner
389}
390
391/// Paint one beveled item: `face`, 1 px white top-left, 1 px black
392/// bottom-right, label left and its marker right-aligned.
393fn paint_row(p: &Painter, r: Rect, row: &Row, face: Color32) {
394    let edge = |min: Pos2, max: Pos2, c: Color32| {
395        p.rect_filled(Rect::from_min_max(min, max), 0.0, c);
396    };
397    p.rect_filled(r, 0.0, face);
398    edge(r.min, pos2(r.max.x, r.min.y + 1.0), WHITE);
399    edge(r.min, pos2(r.min.x + 1.0, r.max.y), WHITE);
400    edge(pos2(r.min.x, r.max.y - 1.0), r.max, BLACK);
401    edge(pos2(r.max.x - 1.0, r.min.y), r.max, BLACK);
402
403    let ink = if row.enabled { BLACK } else { DARK_GRAY };
404    let y = r.center().y;
405    p.text(
406        pos2(r.left() + PAD, y),
407        Align2::LEFT_CENTER,
408        row.label,
409        font(),
410        ink,
411    );
412    let right = r.right() - PAD;
413    if let Some(k) = row.key {
414        p.text(pos2(right, y), Align2::RIGHT_CENTER, k, font(), ink);
415    } else if row.sub {
416        // ponytail: a plain triangle stands in for NeXT's submenu arrow, which
417        // no bundled font has; draw the real shape if the screenshots demand it.
418        p.add(Shape::convex_polygon(
419            vec![
420                pos2(right - 5.0, y - 4.0),
421                pos2(right, y),
422                pos2(right - 5.0, y + 4.0),
423            ],
424            ink,
425            Stroke::NONE,
426        ));
427    } else if row.check {
428        let s = Stroke::new(1.5_f32, ink);
429        p.line_segment([pos2(right - 9.0, y), pos2(right - 6.0, y + 3.5)], s);
430        p.line_segment([pos2(right - 6.0, y + 3.5), pos2(right, y - 4.0)], s);
431    }
432}
433
434#[cfg(test)]
435#[cfg_attr(coverage_nightly, coverage(off))]
436mod tests {
437    use super::*;
438    use egui::{Event, Modifiers, PointerButton, RawInput};
439
440    /// Headless frames of just the menu, with three views ("b" current) and
441    /// Remove Matrix disabled.
442    struct Harness {
443        ctx: Context,
444        time: f64,
445        state: MenuState,
446    }
447
448    impl Harness {
449        fn new() -> Self {
450            let ctx = Context::default();
451            crate::theme::install_fonts(&ctx);
452            let mut h = Self {
453                ctx,
454                time: 0.0,
455                state: MenuState::default(),
456            };
457            // The sizing pass, then a frame whose rows take clicks.
458            h.frame(Vec::new());
459            h.frame(Vec::new());
460            h
461        }
462
463        fn frame(&mut self, events: Vec<Event>) -> Option<Command> {
464            self.time += 1.0 / 60.0;
465            let raw = RawInput {
466                time: Some(self.time),
467                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
468                events,
469                ..Default::default()
470            };
471            let views = ["a", "b", "c"].map(|v| (v.to_owned(), v == "b"));
472            let mut picked = None;
473            let state = &mut self.state;
474            let _ = self.ctx.run(raw, |ctx| {
475                picked = show(ctx, state, pos2(4.0, 4.0), &views, |c| c != C::RemoveMatrix);
476            });
477            picked
478        }
479
480        fn rect(&self, id: Id) -> Rect {
481            self.ctx.memory(|m| m.area_rect(id)).expect("area shown")
482        }
483
484        /// Click row `n` of the panel `id`, then let a newly opened submenu
485        /// finish its sizing pass and come live.
486        fn click_row(&mut self, id: Id, n: usize) -> Option<Command> {
487            let r = self.rect(id);
488            let at = pos2(
489                r.left() + 10.0,
490                r.top() + BORDER + TITLE_HEIGHT + ITEM_HEIGHT * (n as f32 + 0.5),
491            );
492            let picked = self.frame(vec![
493                Event::PointerMoved(at),
494                button(at, true),
495                button(at, false),
496            ]);
497            self.frame(Vec::new());
498            self.frame(Vec::new());
499            picked
500        }
501    }
502
503    fn button(pos: Pos2, pressed: bool) -> Event {
504        Event::PointerButton {
505            pos,
506            button: PointerButton::Primary,
507            pressed,
508            modifiers: Modifiers::NONE,
509        }
510    }
511
512    fn top(label: &str) -> usize {
513        MENU.iter().position(|m| m.0 == label).unwrap()
514    }
515
516    #[test]
517    fn edit_submenu_cascades_right_and_picks_undo() {
518        let mut h = Harness::new();
519        let edit = top("Edit");
520        assert_eq!(h.click_row(menu_id(), edit), None);
521        assert_eq!(h.state.open, Some(edit));
522        let menu = h.rect(menu_id());
523        let sub = h.rect(sub_id(edit));
524        assert!(sub.left() >= menu.right() - 1.0, "{sub:?} vs {menu:?}");
525        assert!((sub.top() - (menu.top() + TITLE_HEIGHT + ITEM_HEIGHT * edit as f32)).abs() < 1.0);
526
527        assert_eq!(h.click_row(sub_id(edit), 0), Some(C::Undo));
528        assert_eq!(h.state.open, None);
529    }
530
531    #[test]
532    fn clicking_the_open_item_closes_it() {
533        let mut h = Harness::new();
534        let edit = top("Edit");
535        h.click_row(menu_id(), edit);
536        h.click_row(menu_id(), edit);
537        assert_eq!(h.state.open, None);
538    }
539
540    #[test]
541    fn top_level_leaf_and_view_rows_return_commands() {
542        let mut h = Harness::new();
543        assert_eq!(h.click_row(menu_id(), top("Quit")), Some(C::Quit));
544        let view = top("View");
545        h.click_row(menu_id(), view);
546        assert_eq!(h.click_row(sub_id(view), 3), Some(C::OpenView(2)));
547    }
548
549    /// View ▸ Model Browser sits above the views list and dispatches.
550    #[test]
551    fn view_model_browser_dispatches() {
552        let mut h = Harness::new();
553        let view = top("View");
554        h.click_row(menu_id(), view);
555        assert_eq!(h.click_row(sub_id(view), 0), Some(C::ModelBrowser));
556    }
557
558    #[test]
559    fn disabled_rows_return_nothing() {
560        let mut h = Harness::new();
561        assert_eq!(h.click_row(menu_id(), top("Print…")), None);
562        assert_eq!(h.click_row(menu_id(), top("Format")), None);
563        assert_eq!(h.state.open, None, "an empty submenu doesn't open");
564
565        let ws = top("Worksheet");
566        h.click_row(menu_id(), ws);
567        assert_eq!(
568            h.click_row(sub_id(ws), 2),
569            None,
570            "Remove Matrix is disabled"
571        );
572        assert_eq!(h.state.open, Some(ws), "a disabled click leaves it open");
573        let file = top("File");
574        h.click_row(menu_id(), file);
575        assert_eq!(h.click_row(sub_id(file), 0), None, "New isn't built yet");
576    }
577
578    #[test]
579    fn escape_or_a_click_elsewhere_closes_the_submenu() {
580        let mut h = Harness::new();
581        h.click_row(menu_id(), top("Edit"));
582        h.frame(vec![Event::Key {
583            key: Key::Escape,
584            physical_key: None,
585            pressed: true,
586            repeat: false,
587            modifiers: Modifiers::NONE,
588        }]);
589        assert_eq!(h.state.open, None);
590
591        h.click_row(menu_id(), top("Edit"));
592        let away = pos2(600.0, 500.0);
593        h.frame(vec![Event::PointerMoved(away), button(away, true)]);
594        assert_eq!(h.state.open, None);
595    }
596
597    #[test]
598    fn title_drag_moves_the_menu() {
599        let mut h = Harness::new();
600        let from = pos2(30.0, 12.0);
601        let to = from + vec2(100.0, 50.0);
602        h.frame(vec![Event::PointerMoved(from)]);
603        h.frame(vec![button(from, true)]);
604        h.frame(vec![Event::PointerMoved(to)]);
605        h.frame(vec![button(to, false)]);
606        assert_eq!(h.state.pos, Some(pos2(104.0, 54.0)));
607    }
608
609    /// Dragged to the screen's bottom-right corner, the menu opens Worksheet
610    /// to its left and lifts it, keeping the submenu on screen.
611    #[test]
612    fn submenus_stay_on_screen() {
613        let mut h = Harness::new();
614        let menu = h.rect(menu_id());
615        h.state.pos = Some(pos2(800.0 - menu.width(), 450.0));
616        h.frame(Vec::new());
617        let ws = top("Worksheet");
618        h.click_row(menu_id(), ws);
619        assert_eq!(h.state.open, Some(ws));
620        let screen = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
621        let sub = h.rect(sub_id(ws));
622        assert!(screen.contains_rect(sub), "{sub:?} off {screen:?}");
623        assert!(sub.right() <= h.rect(menu_id()).left() + 1.0, "{sub:?}");
624    }
625
626    /// The key table is the menu: every keyed item maps back to itself.
627    #[test]
628    fn key_command_agrees_with_every_keyed_menu_item() {
629        let mut n = 0;
630        for (k, c) in keyed_items() {
631            let key = Key::from_name(&k.to_string()).expect("a key letter");
632            assert_eq!(key_command(key, k.is_uppercase()), c, "{k:?}");
633            n += 1;
634        }
635        assert_eq!(n, 6, "s z Z p h q");
636    }
637
638    #[test]
639    fn key_equivalents_map_to_commands() {
640        assert_eq!(key_command(Key::S, false), Some(C::SaveModel));
641        assert_eq!(key_command(Key::Z, false), Some(C::Undo));
642        assert_eq!(key_command(Key::Z, true), Some(C::Redo));
643        assert_eq!(key_command(Key::Y, false), Some(C::Redo));
644        assert_eq!(key_command(Key::Q, false), Some(C::Quit));
645        assert_eq!(key_command(Key::H, false), Some(C::Hide));
646        assert_eq!(key_command(Key::S, true), None);
647        assert_eq!(key_command(Key::P, false), None, "Print is disabled");
648        assert_eq!(key_command(Key::A, false), None);
649    }
650
651    /// Every label and key letter renders from the installed fonts, not as the
652    /// missing-glyph box. Replaces the old tool-palette glyph check.
653    #[test]
654    fn menu_glyphs_are_in_the_installed_fonts() {
655        let h = Harness::new();
656        let mut texts: Vec<String> = vec![NO_VIEWS.into(), MODEL_BROWSER.into()];
657        for (label, key, top) in MENU {
658            texts.push((*label).into());
659            texts.extend(key.map(String::from));
660            if let Top::Sub(leaves) = top {
661                for (label, key, _) in *leaves {
662                    texts.push((*label).into());
663                    texts.extend(key.map(String::from));
664                }
665            }
666        }
667        h.ctx.fonts(|f| {
668            assert!(f.has_glyphs(&bold(), TITLE));
669            for t in &texts {
670                assert!(f.has_glyphs(&font(), t), "missing glyph in {t:?}");
671            }
672        });
673    }
674
675    /// With no saved views the View submenu lists Model Browser then a
676    /// disabled "No Views" placeholder.
677    #[test]
678    fn view_submenu_without_views_shows_the_placeholder() {
679        let ctx = Context::default();
680        crate::theme::install_fonts(&ctx);
681        let mut state = MenuState {
682            open: Some(top("View")),
683            pos: None,
684        };
685        let mut texts = Vec::new();
686        for t in 0..3 {
687            let raw = RawInput {
688                time: Some(f64::from(t) / 60.0),
689                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
690                ..Default::default()
691            };
692            let out = ctx.run(raw, |ctx| {
693                assert_eq!(show(ctx, &mut state, pos2(4.0, 4.0), &[], |_| true), None);
694            });
695            texts = out
696                .shapes
697                .iter()
698                .filter_map(|c| match &c.shape {
699                    egui::Shape::Text(t) => Some(t.galley.text().to_owned()),
700                    _ => None,
701                })
702                .collect();
703        }
704        assert!(texts.iter().any(|t| t == NO_VIEWS), "{texts:?}");
705        assert!(texts.iter().any(|t| t == MODEL_BROWSER), "{texts:?}");
706    }
707}