Skip to main content

blockworx/tools/
main_menu.rs

1//! The hamburger menu in the canvas's upper-left corner: File, Import/Export,
2//! Preferences and Help, each a cascading submenu over the very function that
3//! used to hang from its own toolbar button.
4//!
5//! The toolbar is for the document; this is for the session around it. Nothing
6//! here is hidden when it does not apply — Save on a session with no container
7//! and Import on a read-only one draw dead, the same answer the registry gives
8//! their toolbar-borne siblings.
9
10use crate::{
11    grid::GRID_SIZE,
12    preferences::Preferences,
13    tools::{
14        chrome::Panel,
15        commands::{CommandId, CommandSet},
16        tool::Action,
17        toolbar::{EXPORT_ICON, export_format_menu, icon_image},
18    },
19};
20
21/// Everything the menu's entries read and edit.
22pub struct MainMenu<'a> {
23    pub prefs: &'a mut Preferences,
24    /// The containers the File menu offers to reopen, most recent first.
25    #[cfg(not(target_arch = "wasm32"))]
26    pub recent: &'a [std::path::PathBuf],
27    /// Whether the File menu offers Save — the same value the registry gates
28    /// [`CommandId::Save`] on, so the entry and the name cannot disagree.
29    #[cfg(not(target_arch = "wasm32"))]
30    pub saving: crate::doc::Saving,
31    /// The document the File menu can rename (D20).
32    #[cfg(not(target_arch = "wasm32"))]
33    pub document: crate::tools::file_menu::Document<'a>,
34}
35
36/// Draw the hamburger in `viewport`'s upper-left corner. Returns the action a
37/// click requested and the button's own rect, which the document notices hang
38/// below.
39pub fn main_menu(
40    commands: &mut CommandSet,
41    menu: MainMenu<'_>,
42    viewport: egui::Rect,
43    ui: &mut egui::Ui,
44) -> (Option<Action>, egui::Rect) {
45    let corner = egui::Rect::from_min_max(
46        viewport.left_top() + egui::vec2(GRID_SIZE, GRID_SIZE),
47        viewport.right_bottom(),
48    );
49    let mut child = ui.new_child(
50        Panel::MainMenu
51            .ui_builder()
52            .max_rect(corner)
53            .layout(egui::Layout::top_down(egui::Align::Min)),
54    );
55    // A bare button: a frame around a lone control reads as a bar with one
56    // thing on it (docs/ui-issues.md follow-up, "double encapsulated").
57    let mut action = None;
58    let rect = child
59        .menu_image_button(icon_image(&child, MENU_ICON), |ui| {
60            action = sections(ui, commands, menu);
61        })
62        .response
63        .on_hover_text("Menu")
64        .rect;
65    (action, rect)
66}
67
68/// The menu's four sections, each a cascading submenu.
69fn sections(ui: &mut egui::Ui, commands: &mut CommandSet, menu: MainMenu<'_>) -> Option<Action> {
70    ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
71    let MainMenu {
72        prefs,
73        #[cfg(not(target_arch = "wasm32"))]
74        recent,
75        #[cfg(not(target_arch = "wasm32"))]
76        saving,
77        #[cfg(not(target_arch = "wasm32"))]
78        document,
79    } = menu;
80    let mut action = None;
81    // The document itself: which one is open and where it lives. Native only —
82    // the browser has no container to open, and its persistence is Phase 8's.
83    #[cfg(not(target_arch = "wasm32"))]
84    ui.menu_button("File", |ui| {
85        if let Some(picked) = crate::tools::file_menu::menu(ui, recent, saving, document) {
86            action = Some(picked);
87        }
88    });
89    ui.menu_button("Import/Export", |ui| {
90        if let Some(picked) = import_export(ui, commands) {
91            action = Some(picked);
92        }
93    });
94    ui.menu_button("Preferences", |ui| {
95        crate::preferences_menu::menu(ui, prefs);
96    });
97    ui.menu_button("Help", |ui| {
98        crate::tools::help_menu::menu(ui);
99    });
100    action
101}
102
103/// Diagrams in and out. The format is picked up front rather than in a save
104/// dialog: the web dialog can offer no format chooser, so a dialog-driven
105/// choice would always yield SVG there.
106fn import_export(ui: &mut egui::Ui, commands: &mut CommandSet) -> Option<Action> {
107    ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
108    let mut action = None;
109    // Import lands a document as a block in the current level (D19), so it is
110    // a write like any other and the registry gates it.
111    let import = ui.add_enabled(
112        commands.contains(CommandId::Import),
113        egui::Button::image_and_text(icon_image(ui, IMPORT_ICON), "Import…"),
114    );
115    if import.clicked() {
116        action = commands.take(CommandId::Import);
117    }
118    ui.menu_image_text_button(icon_image(ui, EXPORT_ICON), "Export", |ui| {
119        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
120        if let Some(format) = export_format_menu(ui, crate::export::ExportScope::View) {
121            action = Some(Action::Export {
122                format,
123                selection: None,
124            });
125        }
126    });
127    action
128}
129
130const MENU_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-menu.svg");
131const IMPORT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-import.svg");
132
133#[cfg(all(test, feature = "kittest"))]
134mod kittest_visual {
135    use super::*;
136    use crate::canvas::palette::Luminance;
137    use crate::font::build_fonts;
138    use crate::preferences::{FontChoice, Theme};
139    use egui::vec2;
140    use egui_kittest::Harness;
141    use egui_kittest::kittest::Queryable as _;
142
143    /// The hamburger, open: the four sections that left the toolbar, in the
144    /// canvas's upper-left corner.
145    #[test]
146    fn main_menu_open() {
147        let mut prefs = Preferences::default();
148        let mut draft = String::new();
149        let mut harness = Harness::builder()
150            .with_size(vec2(420.0, 240.0))
151            .build_ui(move |ui| {
152                let ctx = ui.ctx().clone();
153                egui_extras::install_image_loaders(&ctx);
154                ctx.set_fonts(build_fonts(FontChoice::Basic));
155                ctx.set_visuals(Theme::Catppuccin.palette(Luminance::Dark).egui_visuals());
156                let viewport = ui.max_rect();
157                let _ = main_menu(
158                    &mut CommandSet::writable_toolbar(),
159                    MainMenu {
160                        prefs: &mut prefs,
161                        recent: &[],
162                        saving: crate::doc::Saving::Offered,
163                        document: crate::tools::file_menu::Document {
164                            name: "engine",
165                            draft: &mut draft,
166                            renaming: crate::doc::Renaming::Offered,
167                        },
168                    },
169                    viewport,
170                    ui,
171                );
172            });
173        harness.run();
174        // The hamburger is the only button in the harness, and the picture is
175        // of what it opens.
176        harness.get_by_role(egui::accesskit::Role::Button).click();
177        harness.run();
178        harness.snapshot("main_menu");
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::doc::{Saving, Viewing, Writability};
186    use crate::tools::commands::{CommandContext, History};
187    use crate::tools::painted::Chrome;
188    use crate::widget::test_fixtures::Scene;
189
190    /// The sections the menu is for, in the order it lists them. The browser
191    /// has no File section — it has no container to open — so the native list
192    /// is the longer one.
193    #[cfg(not(target_arch = "wasm32"))]
194    const SECTIONS: [&str; 4] = ["File", "Import/Export", "Preferences", "Help"];
195    #[cfg(target_arch = "wasm32")]
196    const SECTIONS: [&str; 3] = ["Import/Export", "Preferences", "Help"];
197
198    /// What the session's document is called, which the rename box opens on.
199    const DOCUMENT: &str = "engine";
200
201    /// The session the menu is a view of: the registry it resolves against,
202    /// what it edited, and what a click dispatched.
203    struct Session {
204        writability: Writability,
205        prefs: Preferences,
206        /// The rename box's text, which the menu owns across frames.
207        draft: String,
208        renaming: crate::doc::Renaming,
209        scene: Scene,
210        fired: Option<Action>,
211        button: egui::Rect,
212    }
213
214    impl Session {
215        /// One real frame: the frame's own `CommandSet`, and the menu drawn
216        /// from it.
217        fn frame(&mut self, ui: &mut egui::Ui) {
218            let viewport = ui.max_rect();
219            let tool: crate::tools::tool::Tool = crate::tools::SelectTool.into();
220            let drawing = self.scene.drawing();
221            let mut commands = CommandSet::available(&CommandContext {
222                tool: &tool,
223                data: &drawing,
224                history: History {
225                    can_undo: false,
226                    can_redo: false,
227                },
228                current_lock: crate::edit::naming::InterfaceLock::Unlocked,
229                writability: self.writability,
230                head: blockworx_doc::rev::Rev::ZERO,
231                saving: Saving::Withheld,
232                viewing: Viewing::Head,
233            });
234            let (action, rect) = main_menu(
235                &mut commands,
236                MainMenu {
237                    prefs: &mut self.prefs,
238                    #[cfg(not(target_arch = "wasm32"))]
239                    recent: &[],
240                    #[cfg(not(target_arch = "wasm32"))]
241                    saving: Saving::Withheld,
242                    #[cfg(not(target_arch = "wasm32"))]
243                    document: crate::tools::file_menu::Document {
244                        name: DOCUMENT,
245                        draft: &mut self.draft,
246                        renaming: self.renaming,
247                    },
248                },
249                viewport,
250                ui,
251            );
252            self.button = rect;
253            if let Some(action) = action {
254                self.fired = Some(action);
255            }
256        }
257    }
258
259    /// The hamburger and nothing else, driven through real frames and clicked
260    /// through egui's own hit-testing.
261    struct Menu {
262        chrome: Chrome,
263        session: Session,
264    }
265
266    impl Menu {
267        fn new(writability: Writability) -> Self {
268            let screen = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(600.0, 500.0));
269            let mut menu = Self {
270                chrome: Chrome::new(screen),
271                session: Session {
272                    writability,
273                    prefs: Preferences::default(),
274                    draft: String::new(),
275                    renaming: crate::doc::Renaming::Offered,
276                    scene: Scene::new(Vec::new()),
277                    fired: None,
278                    button: egui::Rect::NOTHING,
279                },
280            };
281            menu.settle();
282            assert!(
283                menu.session.button.is_positive(),
284                "the hamburger never laid out: {:?}",
285                menu.session.button,
286            );
287            menu
288        }
289
290        fn settle(&mut self) {
291            let Self { chrome, session } = self;
292            chrome.settle(|ui| session.frame(ui));
293        }
294
295        fn click_at(&mut self, at: egui::Pos2) {
296            let Self { chrome, session } = self;
297            chrome.click_at(at, |ui| session.frame(ui));
298        }
299
300        fn click_on(&mut self, label: &str) {
301            let Self { chrome, session } = self;
302            chrome.click_on(label, |ui| session.frame(ui));
303        }
304
305        fn type_text(&mut self, text: &str) {
306            let Self { chrome, session } = self;
307            chrome.type_text(text, |ui| session.frame(ui));
308        }
309
310        /// Open the hamburger.
311        fn open(&mut self) {
312            let at = self.session.button.center();
313            self.click_at(at);
314        }
315    }
316
317    /// Opening the hamburger offers the four things that left the toolbar,
318    /// each a submenu of its own — clicked through a real frame, so a section
319    /// that lays out but opens onto nothing fails here.
320    #[test]
321    fn the_hamburger_carries_file_import_export_preferences_and_help() {
322        let mut menu = Menu::new(Writability::Writable);
323        menu.open();
324        for section in SECTIONS {
325            assert!(
326                menu.chrome.shows(section),
327                "the menu offers no {section} section; it drew {:?}",
328                menu.chrome.texts(),
329            );
330        }
331        menu.click_on("Import/Export");
332        assert!(
333            menu.chrome.shows("Import…"),
334            "Import/Export opened onto nothing: {:?}",
335            menu.chrome.texts(),
336        );
337        menu.click_on("Export");
338        for format in crate::export::ExportScope::View.formats() {
339            assert!(
340                menu.chrome.shows(format.label()),
341                "the view's Export menu is missing {}: {:?}",
342                format.label(),
343                menu.chrome.texts(),
344            );
345        }
346    }
347
348    /// The preferences and help sections open onto the same entries their
349    /// toolbar buttons used to.
350    #[test]
351    fn preferences_and_help_keep_their_entries() {
352        let mut menu = Menu::new(Writability::Writable);
353        menu.open();
354        menu.click_on("Preferences");
355        assert!(
356            menu.chrome.shows("Theme"),
357            "the preferences submenu lost its entries: {:?}",
358            menu.chrome.texts(),
359        );
360        menu.click_on("Help");
361        assert!(
362            menu.chrome.shows("GitHub"),
363            "the help submenu lost its entries: {:?}",
364            menu.chrome.texts(),
365        );
366    }
367
368    /// A read-only session still lists Import — the menu keeps its shape —
369    /// but the entry is dead, because the registry withheld the command
370    /// rather than the menu forming a second opinion about writability.
371    #[test]
372    fn import_is_listed_in_a_read_only_session_and_does_nothing() {
373        let mut writable = Menu::new(Writability::Writable);
374        writable.open();
375        writable.click_on("Import/Export");
376        writable.click_on("Import…");
377        assert!(
378            matches!(writable.session.fired, Some(Action::Import)),
379            "a writable session's Import dispatched something else",
380        );
381
382        let mut read_only = Menu::new(Writability::ReadOnly);
383        read_only.open();
384        read_only.click_on("Import/Export");
385        assert!(
386            read_only.chrome.shows("Import…"),
387            "a read-only session hid Import instead of disabling it",
388        );
389        read_only.click_on("Import…");
390        assert!(
391            read_only.session.fired.is_none(),
392            "a read-only session's Import dispatched",
393        );
394    }
395
396    /// Renaming is an inline box in a cascading submenu, not a dialog: the
397    /// box opens on the document's own name, takes what is typed into it,
398    /// and dispatches exactly that. Driven through real frames, so a box
399    /// that never took the keyboard fails here.
400    #[cfg(not(target_arch = "wasm32"))]
401    #[test]
402    fn the_file_menu_renames_the_document_through_an_inline_box() {
403        let mut menu = Menu::new(Writability::Writable);
404        menu.open();
405        menu.click_on("File");
406        assert_eq!(
407            menu.session.draft, DOCUMENT,
408            "the box did not open on the document's own name",
409        );
410        menu.click_on("Rename…");
411        menu.type_text("2");
412        assert_ne!(menu.session.draft, DOCUMENT, "typing never reached the box");
413        let typed = menu.session.draft.clone();
414
415        menu.click_on("Rename");
416        let Some(Action::RenameDocument(dispatched)) = &menu.session.fired else {
417            panic!(
418                "the box dispatched {}",
419                menu.session
420                    .fired
421                    .as_ref()
422                    .map_or("nothing", crate::tools::commands::action_name),
423            );
424        };
425        assert_eq!(*dispatched, typed, "what was typed is not what was sent");
426    }
427
428    /// A session that may not write its container may not rename it either
429    /// — same question, same answer — and the entry draws dead rather than
430    /// disappearing.
431    #[cfg(not(target_arch = "wasm32"))]
432    #[test]
433    fn a_session_that_cannot_write_cannot_rename() {
434        let mut menu = Menu::new(Writability::ReadOnly);
435        menu.session.renaming = crate::doc::Renaming::Withheld;
436        menu.open();
437        menu.click_on("File");
438        assert!(
439            menu.chrome.shows("Rename…"),
440            "the read-only menu hid Rename instead of disabling it",
441        );
442        menu.click_on("Rename…");
443        assert!(
444            !menu.chrome.shows("Rename"),
445            "a read-only session's rename box opened: {:?}",
446            menu.chrome.texts(),
447        );
448    }
449
450    /// The tools are the toolbar's; the menu holds none of them.
451    #[test]
452    fn the_menu_holds_no_tools() {
453        let mut menu = Menu::new(Writability::Writable);
454        menu.open();
455        for tool in crate::tools::names::TOOLBAR_TOOLS {
456            assert!(
457                !menu.chrome.shows(tool.label()),
458                "{tool:?} strayed into the main menu",
459            );
460        }
461    }
462}