1use 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
18pub enum Command {
19 InfoPanel,
20 ModelBrowser,
22 SaveModel,
23 RevertToSaved,
26 Undo,
27 Redo,
28 PivotRotate,
29 AddMatrix,
30 RemoveMatrix,
31 SaveView,
32 OpenView(usize),
34 ToggleChart,
35 ToggleCsv,
36 FunctionHelp,
38 Hide,
39 Quit,
40}
41
42type Leaf = (&'static str, Option<char>, Option<Command>);
45
46enum Top {
48 Sub(&'static [Leaf]),
50 Views,
53 Leaf(Option<Command>),
55}
56
57use Command as C;
58
59const 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
120const BORDER: f32 = 1.0;
122const TITLE_HEIGHT: f32 = 23.0;
123const ITEM_HEIGHT: f32 = 20.0;
124const PAD: f32 = 6.0;
125const RIGHT_SLOT: f32 = 28.0;
127const MIN_WIDTH: f32 = 96.0;
128pub(crate) const MENU_W: f32 = 120.0;
131const FONT_SIZE: f32 = 12.0;
132
133#[derive(Clone, Copy, Debug, Default)]
135pub struct MenuState {
136 open: Option<usize>,
138 pos: Option<Pos2>,
141}
142
143pub 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
155fn 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
171pub 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 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 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 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
292pub 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
301struct Row<'a> {
303 label: &'a str,
304 key: Option<char>,
305 sub: bool,
307 check: bool,
309 enabled: bool,
310 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
322fn 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
337fn 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
391fn 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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}