1pub mod items;
50pub mod panel;
51
52pub use items::{
53 MenuAction, MenuEntry, MenuGroup, MenuIcon, MenuNode, MenuSelectable, MenuSelection,
54 MenuSubmenu, MenuToggleable, menu_entry, menu_group, menu_selectable, menu_submenu,
55 menu_toggleable, partition_surfaces,
56};
57pub use panel::{MenuPanelView, MenuPanelWidget, menu_panel};
58
59use std::rc::Rc;
60
61use frust::authoring::{BuildCtx, ChangeFlags, TypedArgCallback, View};
62use frust::{Color, Theme};
63
64use crate::interaction::DISABLED_CONTENT_OPACITY;
65use crate::overlay::{
66 AnchoredOverlayView, AnchoredOverlayWidget, OVERLAY_ANCHOR_GAP, OverlayAlign, OverlayAnchor,
67 OverlayPlacement, OverlaySide, anchored_overlay, with_alpha,
68};
69use crate::tokens::{MaterialDimensions, MaterialSpacing};
70
71pub const MENU_MIN_WIDTH: f64 = 112.0;
79pub const MENU_MAX_WIDTH: f64 = 280.0;
81pub const MENU_MAX_HEIGHT: f64 = 320.0;
83pub const MENU_ROW_MIN_HEIGHT: f64 = 48.0;
85pub const MENU_ICON_SIZE: f64 = 24.0;
87
88pub(crate) const MENU_SURFACE_V_PADDING: f64 = MaterialSpacing::SM;
90pub(crate) const MENU_SURFACE_H_PADDING: f64 = MaterialSpacing::SM;
92pub(crate) const MENU_SECTION_GAP: f64 = MaterialSpacing::SM;
94pub(crate) const MENU_ROW_H_PADDING: f64 = MaterialSpacing::MD;
96pub(crate) const MENU_ICON_GAP: f64 = MaterialSpacing::MD;
98pub(crate) const MENU_ITEM_GAP: f64 = MaterialSpacing::XS;
100pub(crate) const MENU_GROUP_LABEL_H_PADDING: f64 = MaterialSpacing::MD;
102pub(crate) const MENU_GROUP_LABEL_V_PADDING: f64 = MaterialSpacing::SM;
104pub(crate) const MENU_CONTAINER_RADIUS: f64 = MaterialDimensions::RADIUS_LARGE;
107pub(crate) const MENU_ITEM_RADIUS: f64 = MaterialDimensions::RADIUS_MEDIUM;
110pub(crate) const MENU_SUPPORTING_V_PADDING: f64 = MaterialSpacing::SM;
113pub(crate) const MENU_DIVIDER_V_PADDING: f64 = MaterialSpacing::XS;
115pub(crate) const MENU_DIVIDER_THICKNESS: f64 = 1.0;
117pub(crate) const MENU_SUBMENU_GAP: f64 = OVERLAY_ANCHOR_GAP;
120pub(crate) const SELECTED_CHECK_SCALE: f64 = 0.9;
123
124pub(crate) fn container_radius(theme: Option<&Theme>) -> f64 {
132 theme.map_or(MENU_CONTAINER_RADIUS, |t| t.shape.large)
133}
134
135pub(crate) fn item_radius(theme: Option<&Theme>) -> f64 {
138 theme.map_or(MENU_ITEM_RADIUS, |t| t.shape.medium)
139}
140
141const FALLBACK_SURFACE_CONTAINER_LOW: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
145const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
147const FALLBACK_ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
149const FALLBACK_TERTIARY_CONTAINER: Color = Color::from_rgb8(0xFF, 0xD8, 0xE4);
151const FALLBACK_ON_TERTIARY_CONTAINER: Color = Color::from_rgb8(0x31, 0x11, 0x1D);
153const FALLBACK_TERTIARY: Color = Color::from_rgb8(0x7D, 0x52, 0x60);
155const FALLBACK_ON_TERTIARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
157const FALLBACK_OUTLINE_VARIANT: Color = Color::from_rgb8(0xCA, 0xC4, 0xD0);
159const FALLBACK_ERROR: Color = Color::from_rgb8(0xB3, 0x26, 0x1E);
161
162const VIBRANT_DIVIDER_ALPHA: f32 = 0.24;
165
166#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
169pub enum MenuColorStyle {
170 #[default]
173 Standard,
174 Vibrant,
176}
177
178#[derive(Clone, Copy, Debug, PartialEq)]
180pub struct MenuColors {
181 pub container: Color,
183 pub content: Color,
185 pub icon_content: Color,
187 pub supporting_content: Color,
189 pub selected_container: Color,
191 pub selected_content: Color,
193 pub state_layer: Color,
195 pub divider: Color,
197}
198
199impl MenuColors {
200 pub fn resolve(theme: Option<&Theme>, style: MenuColorStyle) -> Self {
203 let (
204 surface_container_low,
205 on_surface,
206 on_surface_variant,
207 tertiary_container,
208 on_tertiary_container,
209 tertiary,
210 on_tertiary,
211 outline_variant,
212 ) = match theme {
213 Some(theme) => {
214 let s = theme.scheme();
215 (
216 s.surface_container_low,
217 s.on_surface,
218 s.on_surface_variant,
219 s.tertiary_container,
220 s.on_tertiary_container,
221 s.tertiary,
222 s.on_tertiary,
223 s.outline_variant,
224 )
225 }
226 None => (
227 FALLBACK_SURFACE_CONTAINER_LOW,
228 FALLBACK_ON_SURFACE,
229 FALLBACK_ON_SURFACE_VARIANT,
230 FALLBACK_TERTIARY_CONTAINER,
231 FALLBACK_ON_TERTIARY_CONTAINER,
232 FALLBACK_TERTIARY,
233 FALLBACK_ON_TERTIARY,
234 FALLBACK_OUTLINE_VARIANT,
235 ),
236 };
237 match style {
238 MenuColorStyle::Standard => MenuColors {
239 container: surface_container_low,
240 content: on_surface,
241 icon_content: on_surface_variant,
242 supporting_content: on_surface_variant,
243 selected_container: tertiary_container,
244 selected_content: on_tertiary_container,
245 state_layer: on_surface,
246 divider: outline_variant,
247 },
248 MenuColorStyle::Vibrant => MenuColors {
249 container: tertiary_container,
250 content: on_tertiary_container,
251 icon_content: on_tertiary_container,
252 supporting_content: on_tertiary_container,
253 selected_container: tertiary,
254 selected_content: on_tertiary,
255 state_layer: on_tertiary_container,
256 divider: with_alpha(on_tertiary_container, VIBRANT_DIVIDER_ALPHA),
257 },
258 }
259 }
260
261 pub fn entry_foreground(&self, enabled: bool, destructive: bool, selected: bool) -> Color {
265 if !enabled {
266 return with_alpha(self.content, DISABLED_CONTENT_OPACITY);
267 }
268 if destructive {
269 return self.error();
270 }
271 if selected {
272 self.selected_content
273 } else {
274 self.content
275 }
276 }
277
278 pub fn icon_foreground(&self, enabled: bool, destructive: bool, selected: bool) -> Color {
281 if !enabled {
282 return with_alpha(self.icon_content, DISABLED_CONTENT_OPACITY);
283 }
284 if destructive {
285 return self.error();
286 }
287 if selected {
288 self.selected_content
289 } else {
290 self.icon_content
291 }
292 }
293
294 pub fn supporting_foreground(&self, enabled: bool, selected: bool) -> Color {
298 if !enabled {
299 return with_alpha(self.content, DISABLED_CONTENT_OPACITY);
300 }
301 if selected {
302 self.selected_content
303 } else {
304 self.supporting_content
305 }
306 }
307
308 fn error(&self) -> Color {
313 FALLBACK_ERROR
314 }
315}
316
317pub struct MenuView<State: 'static> {
325 nodes: Vec<MenuNode>,
326 style: MenuColorStyle,
327 selected: Option<String>,
328 open: bool,
329 placement: OverlayPlacement,
330 anchor: OverlayAnchor,
331 on_select: TypedArgCallback<State, MenuSelection>,
332 on_dismiss: Option<OnDismiss<State>>,
333}
334
335type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
338
339pub fn menu<State: 'static, F: Fn(&mut State, MenuSelection) + 'static>(
350 nodes: Vec<MenuNode>,
351 on_select: F,
352) -> MenuView<State> {
353 MenuView {
354 nodes,
355 style: MenuColorStyle::default(),
356 selected: None,
357 open: true,
358 placement: OverlayPlacement::default()
359 .align(OverlayAlign::Start)
360 .offset(OVERLAY_ANCHOR_GAP),
361 anchor: OverlayAnchor::new(),
362 on_select: Rc::new(on_select),
363 on_dismiss: None,
364 }
365}
366
367impl<State: 'static> MenuView<State> {
368 pub fn anchor(mut self, anchor: &OverlayAnchor) -> Self {
371 self.anchor = anchor.clone();
372 self
373 }
374
375 pub fn side(mut self, side: OverlaySide) -> Self {
377 self.placement.side = side;
378 self
379 }
380
381 pub fn align(mut self, align: OverlayAlign) -> Self {
384 self.placement.align = align;
385 self
386 }
387
388 pub fn offset(mut self, offset: f64) -> Self {
390 self.placement.offset = offset;
391 self
392 }
393
394 pub fn color_style(mut self, style: MenuColorStyle) -> Self {
396 self.style = style;
397 self
398 }
399
400 pub fn selected(mut self, value: Option<String>) -> Self {
403 self.selected = value;
404 self
405 }
406
407 pub fn open(mut self, open: bool) -> Self {
411 self.open = open;
412 self
413 }
414
415 pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
418 self.on_dismiss = Some(Rc::new(on_dismiss));
419 self
420 }
421
422 fn host(&self) -> AnchoredOverlayView<State> {
424 let on_select = self.on_select.clone();
425 let panel = menu_panel(self.nodes.clone(), move |state: &mut State, selection| {
426 on_select(state, selection)
427 })
428 .color_style(self.style)
429 .selected(self.selected.clone())
430 .open(self.open);
431 let mut host = anchored_overlay(panel)
432 .anchor(&self.anchor)
433 .placement(self.placement)
434 .open(self.open);
435 if let Some(on_dismiss) = self.on_dismiss.clone() {
436 host = host.on_dismiss(move |state: &mut State| on_dismiss(state));
437 }
438 host
439 }
440}
441
442impl<State: 'static> View<State> for MenuView<State> {
443 type Element = AnchoredOverlayWidget;
444
445 fn build(&self, ctx: &mut BuildCtx<'_>) -> AnchoredOverlayWidget {
446 View::build(&self.host(), ctx)
447 }
448
449 fn rebuild(
450 &self,
451 prev: &Self,
452 element: &mut AnchoredOverlayWidget,
453 ctx: &mut BuildCtx<'_>,
454 ) -> ChangeFlags {
455 View::rebuild(&self.host(), &prev.host(), element, ctx)
456 }
457
458 fn teardown(&self, element: &mut AnchoredOverlayWidget, ctx: &mut BuildCtx<'_>) {
459 View::teardown(&self.host(), element, ctx);
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use frust::authoring::{
467 Color, InputEvent, PaintScene, Point, PointerButton, PointerEvent, PointerPhase, Rect,
468 Size, text::TextContext,
469 };
470 use frust::{Brightness, FrameTime};
471 use frust_core::RenderRoot;
472 use std::any::Any;
473
474 #[test]
475 fn standard_resolves_the_reference_s_surface_roles() {
476 let theme = crate::baseline().with_brightness(Brightness::Light);
477 let s = theme.scheme();
478 let colors = MenuColors::resolve(Some(&theme), MenuColorStyle::Standard);
479 assert_eq!(colors.container, s.surface_container_low);
480 assert_eq!(colors.content, s.on_surface);
481 assert_eq!(colors.icon_content, s.on_surface_variant);
482 assert_eq!(colors.supporting_content, s.on_surface_variant);
483 assert_eq!(colors.selected_container, s.tertiary_container);
484 assert_eq!(colors.selected_content, s.on_tertiary_container);
485 assert_eq!(colors.state_layer, s.on_surface);
486 assert_eq!(colors.divider, s.outline_variant);
487 }
488
489 #[test]
490 fn vibrant_promotes_the_whole_surface_to_the_tertiary_family() {
491 let theme = crate::baseline().with_brightness(Brightness::Light);
492 let s = theme.scheme();
493 let colors = MenuColors::resolve(Some(&theme), MenuColorStyle::Vibrant);
494 assert_eq!(colors.container, s.tertiary_container);
495 assert_eq!(colors.content, s.on_tertiary_container);
496 assert_eq!(colors.icon_content, s.on_tertiary_container);
497 assert_eq!(colors.selected_container, s.tertiary);
498 assert_eq!(colors.selected_content, s.on_tertiary);
499 assert_eq!(colors.state_layer, s.on_tertiary_container);
500 assert_eq!(
501 colors.divider,
502 with_alpha(s.on_tertiary_container, VIBRANT_DIVIDER_ALPHA)
503 );
504 }
505
506 #[test]
507 fn the_two_styles_never_resolve_to_the_same_surface() {
508 let theme = crate::baseline();
509 let standard = MenuColors::resolve(Some(&theme), MenuColorStyle::Standard);
510 let vibrant = MenuColors::resolve(Some(&theme), MenuColorStyle::Vibrant);
511 assert_ne!(standard.container, vibrant.container);
512 assert_ne!(standard.content, vibrant.content);
513 assert_eq!(MenuColorStyle::default(), MenuColorStyle::Standard);
514 }
515
516 #[test]
517 fn the_unthemed_fallbacks_are_the_m3_baseline_light_values() {
518 let light = crate::baseline().with_brightness(Brightness::Light);
519 for style in [MenuColorStyle::Standard, MenuColorStyle::Vibrant] {
520 assert_eq!(
521 MenuColors::resolve(None, style),
522 MenuColors::resolve(Some(&light), style),
523 "{style:?}"
524 );
525 }
526 }
527
528 #[test]
529 fn the_colors_follow_a_live_brightness_flip() {
530 let light = crate::baseline().with_brightness(Brightness::Light);
531 let dark = crate::baseline().with_brightness(Brightness::Dark);
532 assert_ne!(
533 MenuColors::resolve(Some(&light), MenuColorStyle::Standard).container,
534 MenuColors::resolve(Some(&dark), MenuColorStyle::Standard).container
535 );
536 }
537
538 #[test]
539 fn the_foreground_ladder_is_disabled_then_destructive_then_selected() {
540 let colors = MenuColors::resolve(None, MenuColorStyle::Standard);
541 assert_eq!(
542 colors.entry_foreground(false, false, false),
543 with_alpha(colors.content, DISABLED_CONTENT_OPACITY),
544 "disabled outranks everything"
545 );
546 assert_eq!(
547 colors.entry_foreground(false, true, true),
548 with_alpha(colors.content, DISABLED_CONTENT_OPACITY)
549 );
550 assert_eq!(
551 colors.entry_foreground(true, true, true),
552 FALLBACK_ERROR,
553 "destructive outranks selected"
554 );
555 assert_eq!(
556 colors.entry_foreground(true, false, true),
557 colors.selected_content
558 );
559 assert_eq!(colors.entry_foreground(true, false, false), colors.content);
560
561 assert_eq!(
563 colors.icon_foreground(true, false, false),
564 colors.icon_content
565 );
566 assert_eq!(
567 colors.icon_foreground(true, false, true),
568 colors.selected_content
569 );
570 assert_eq!(colors.icon_foreground(true, true, false), FALLBACK_ERROR);
571
572 assert_eq!(
575 colors.supporting_foreground(false, false),
576 with_alpha(colors.content, DISABLED_CONTENT_OPACITY)
577 );
578 assert_eq!(
579 colors.supporting_foreground(true, false),
580 colors.supporting_content
581 );
582 assert_eq!(
583 colors.supporting_foreground(true, true),
584 colors.selected_content
585 );
586 }
587
588 #[test]
589 fn a_non_material_theme_still_resolves_every_role() {
590 let bare = Theme::neutral();
592 let _ = MenuColors::resolve(Some(&bare), MenuColorStyle::Vibrant);
593 }
594
595 const WINDOW: Size = Size::new(400.0, 600.0);
598 const ANCHOR: Rect = Rect::new(40.0, 100.0, 160.0, 140.0);
599
600 #[derive(Default)]
602 struct AppState {
603 dismissed: u32,
604 selections: Vec<MenuSelection>,
605 }
606
607 #[derive(Default)]
608 struct Recorder {
609 rrects: Vec<(Point, Size)>,
610 }
611
612 impl PaintScene for Recorder {
613 fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
614 fn fill_rounded_rect(&mut self, origin: Point, size: Size, _radius: f64, _color: Color) {
615 self.rrects.push((origin, size));
616 }
617 fn draw_text(&mut self, _origin: Point, _text: &str) {}
618 }
619
620 struct Harness {
623 root: RenderRoot<AppState, frust::StackView<AppState>>,
624 state: AppState,
625 tcx: TextContext,
626 anchor: OverlayAnchor,
627 }
628
629 impl Harness {
630 fn new(nodes: Vec<MenuNode>) -> Self {
631 let anchor = OverlayAnchor::new();
632 anchor.set(ANCHOR);
633 let mut h = Harness {
634 root: RenderRoot::new(),
635 state: AppState::default(),
636 tcx: TextContext::new(),
637 anchor,
638 };
639 h.pass(nodes);
640 h
641 }
642
643 fn pass(&mut self, nodes: Vec<MenuNode>) {
644 let anchor = self.anchor.clone();
645 let mut logic = move |_s: &mut AppState| {
646 frust::stack().child(
647 menu(nodes.clone(), |s: &mut AppState, selection| {
648 s.selections.push(selection)
649 })
650 .anchor(&anchor)
651 .on_dismiss(|s: &mut AppState| s.dismissed += 1),
652 )
653 };
654 self.root.rebuild(&mut logic, &mut self.state);
655 self.root
656 .layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
657 }
658
659 fn paint(&mut self) -> Recorder {
660 let mut rec = Recorder::default();
661 self.root.paint(&mut rec, FrameTime::ZERO);
662 rec
663 }
664
665 fn pointer(&mut self, phase: PointerPhase, x: f64, y: f64) {
666 self.root.event(
667 &mut self.state,
668 &InputEvent::Pointer(PointerEvent {
669 phase,
670 position: Point::new(x, y),
671 button: PointerButton::Primary,
672 }),
673 );
674 }
675
676 fn click(&mut self, x: f64, y: f64) {
677 self.pointer(PointerPhase::Down, x, y);
678 self.pointer(PointerPhase::Up, x, y);
679 }
680 }
681
682 fn first_row_center() -> Point {
685 Point::new(
686 ANCHOR.x0 + MENU_MIN_WIDTH / 2.0,
687 ANCHOR.y1
688 + OVERLAY_ANCHOR_GAP
689 + MENU_SURFACE_V_PADDING
690 + (MENU_ITEM_GAP + MENU_ROW_MIN_HEIGHT) / 2.0,
691 )
692 }
693
694 #[test]
695 fn the_menu_places_its_panel_under_the_trigger_at_the_anchor_gap() {
696 let mut h = Harness::new(vec![menu_entry("Cut").into()]);
697 let rec = h.paint();
698 let surface = rec.rrects.first().expect("the menu surface");
699 assert_eq!(
700 surface.0,
701 Point::new(ANCHOR.x0, ANCHOR.y1 + OVERLAY_ANCHOR_GAP),
702 "the reference's `bottomStart`, at `anchorOffset`"
703 );
704 }
705
706 #[test]
707 fn a_press_on_a_row_reports_through_the_host_and_never_dismisses() {
708 let mut h = Harness::new(vec![menu_entry("Cut").into()]);
709 let c = first_row_center();
710 h.click(c.x, c.y);
711 assert_eq!(h.state.selections.len(), 1);
712 assert_eq!(h.state.selections[0].label, "Cut");
713 assert_eq!(
714 h.state.dismissed, 0,
715 "a row press is the panel's, not a dismiss"
716 );
717 }
718
719 #[test]
720 fn a_press_outside_the_panel_light_dismisses_the_whole_menu() {
721 let mut h = Harness::new(vec![
722 menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
723 ]);
724 let c = first_row_center();
726 h.click(c.x, c.y);
727 assert!(h.state.selections.is_empty(), "opening is not selecting");
728
729 h.pointer(PointerPhase::Down, 5.0, 5.0);
730 assert_eq!(h.state.dismissed, 1);
731 assert!(
732 h.state.selections.is_empty(),
733 "the dismissing press never reached a row"
734 );
735 }
736
737 #[test]
738 fn a_closed_kept_mounted_menu_paints_nothing_once_the_host_settles() {
739 let anchor = OverlayAnchor::new();
740 anchor.set(ANCHOR);
741 let mut root: RenderRoot<AppState, MenuView<AppState>> = RenderRoot::new();
742 let mut state = AppState::default();
743 let mut tcx = TextContext::new();
744 let cell = anchor.clone();
745 let mut closed = move |_s: &mut AppState| {
746 menu(vec![menu_entry("Cut").into()], |_s: &mut AppState, _| {})
747 .anchor(&cell)
748 .open(false)
749 };
750 root.rebuild(&mut closed, &mut state);
751 root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
752 let mut rec = Recorder::default();
753 root.paint(&mut rec, FrameTime::ZERO);
754 assert!(
755 rec.rrects.is_empty(),
756 "a host mounted closed shows no panel at all"
757 );
758 }
759
760 #[test]
761 fn the_metrics_are_the_reference_s_menu_theme_defaults() {
762 assert_eq!(MENU_MIN_WIDTH, 112.0);
763 assert_eq!(MENU_MAX_WIDTH, 280.0);
764 assert_eq!(MENU_MAX_HEIGHT, 320.0);
765 assert_eq!(MENU_ROW_MIN_HEIGHT, 48.0);
766 assert_eq!(MENU_ICON_SIZE, 24.0);
767 assert_eq!(MENU_SURFACE_V_PADDING, 8.0);
768 assert_eq!(MENU_SURFACE_H_PADDING, 8.0);
769 assert_eq!(MENU_SECTION_GAP, 8.0);
770 assert_eq!(MENU_ROW_H_PADDING, 12.0);
771 assert_eq!(MENU_ICON_GAP, 12.0);
772 assert_eq!(MENU_ITEM_GAP, 4.0);
773 assert_eq!(MENU_GROUP_LABEL_H_PADDING, 12.0);
774 assert_eq!(MENU_GROUP_LABEL_V_PADDING, 8.0);
775 assert_eq!(MENU_CONTAINER_RADIUS, 16.0);
776 assert_eq!(MENU_ITEM_RADIUS, 12.0);
777 assert_eq!(MENU_SUBMENU_GAP, 4.0);
778 assert_eq!(SELECTED_CHECK_SCALE, 0.9);
779 assert_eq!(DISABLED_CONTENT_OPACITY, 0.38);
781 }
782}