1use 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
21pub struct MainMenu<'a> {
23 pub prefs: &'a mut Preferences,
24 #[cfg(not(target_arch = "wasm32"))]
26 pub recent: &'a [std::path::PathBuf],
27 #[cfg(not(target_arch = "wasm32"))]
30 pub saving: crate::doc::Saving,
31 #[cfg(not(target_arch = "wasm32"))]
33 pub document: crate::tools::file_menu::Document<'a>,
34}
35
36pub 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 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
68fn 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 #[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
103fn 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 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 #[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 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 #[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 const DOCUMENT: &str = "engine";
200
201 struct Session {
204 writability: Writability,
205 prefs: Preferences,
206 draft: String,
208 renaming: crate::doc::Renaming,
209 scene: Scene,
210 fired: Option<Action>,
211 button: egui::Rect,
212 }
213
214 impl Session {
215 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 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 fn open(&mut self) {
312 let at = self.session.button.center();
313 self.click_at(at);
314 }
315 }
316
317 #[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 #[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 #[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 #[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 #[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 #[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}