pub mod items;
pub mod panel;
pub use items::{
MenuAction, MenuEntry, MenuGroup, MenuIcon, MenuNode, MenuSelectable, MenuSelection,
MenuSubmenu, MenuToggleable, menu_entry, menu_group, menu_selectable, menu_submenu,
menu_toggleable, partition_surfaces,
};
pub use panel::{MenuPanelView, MenuPanelWidget, menu_panel};
use std::rc::Rc;
use frust::authoring::{BuildCtx, ChangeFlags, TypedArgCallback, View};
use frust::{Color, Theme};
use crate::interaction::DISABLED_CONTENT_OPACITY;
use crate::overlay::{
AnchoredOverlayView, AnchoredOverlayWidget, OVERLAY_ANCHOR_GAP, OverlayAlign, OverlayAnchor,
OverlayPlacement, OverlaySide, anchored_overlay, with_alpha,
};
use crate::tokens::{MaterialDimensions, MaterialSpacing};
pub const MENU_MIN_WIDTH: f64 = 112.0;
pub const MENU_MAX_WIDTH: f64 = 280.0;
pub const MENU_MAX_HEIGHT: f64 = 320.0;
pub const MENU_ROW_MIN_HEIGHT: f64 = 48.0;
pub const MENU_ICON_SIZE: f64 = 24.0;
pub(crate) const MENU_SURFACE_V_PADDING: f64 = MaterialSpacing::SM;
pub(crate) const MENU_SURFACE_H_PADDING: f64 = MaterialSpacing::SM;
pub(crate) const MENU_SECTION_GAP: f64 = MaterialSpacing::SM;
pub(crate) const MENU_ROW_H_PADDING: f64 = MaterialSpacing::MD;
pub(crate) const MENU_ICON_GAP: f64 = MaterialSpacing::MD;
pub(crate) const MENU_ITEM_GAP: f64 = MaterialSpacing::XS;
pub(crate) const MENU_GROUP_LABEL_H_PADDING: f64 = MaterialSpacing::MD;
pub(crate) const MENU_GROUP_LABEL_V_PADDING: f64 = MaterialSpacing::SM;
pub(crate) const MENU_CONTAINER_RADIUS: f64 = MaterialDimensions::RADIUS_LARGE;
pub(crate) const MENU_ITEM_RADIUS: f64 = MaterialDimensions::RADIUS_MEDIUM;
pub(crate) const MENU_SUPPORTING_V_PADDING: f64 = MaterialSpacing::SM;
pub(crate) const MENU_DIVIDER_V_PADDING: f64 = MaterialSpacing::XS;
pub(crate) const MENU_DIVIDER_THICKNESS: f64 = 1.0;
pub(crate) const MENU_SUBMENU_GAP: f64 = OVERLAY_ANCHOR_GAP;
pub(crate) const SELECTED_CHECK_SCALE: f64 = 0.9;
pub(crate) fn container_radius(theme: Option<&Theme>) -> f64 {
theme.map_or(MENU_CONTAINER_RADIUS, |t| t.shape.large)
}
pub(crate) fn item_radius(theme: Option<&Theme>) -> f64 {
theme.map_or(MENU_ITEM_RADIUS, |t| t.shape.medium)
}
const FALLBACK_SURFACE_CONTAINER_LOW: Color = Color::from_rgb8(0xF7, 0xF2, 0xFA);
const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
const FALLBACK_ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
const FALLBACK_TERTIARY_CONTAINER: Color = Color::from_rgb8(0xFF, 0xD8, 0xE4);
const FALLBACK_ON_TERTIARY_CONTAINER: Color = Color::from_rgb8(0x31, 0x11, 0x1D);
const FALLBACK_TERTIARY: Color = Color::from_rgb8(0x7D, 0x52, 0x60);
const FALLBACK_ON_TERTIARY: Color = Color::from_rgb8(0xFF, 0xFF, 0xFF);
const FALLBACK_OUTLINE_VARIANT: Color = Color::from_rgb8(0xCA, 0xC4, 0xD0);
const FALLBACK_ERROR: Color = Color::from_rgb8(0xB3, 0x26, 0x1E);
const VIBRANT_DIVIDER_ALPHA: f32 = 0.24;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MenuColorStyle {
#[default]
Standard,
Vibrant,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MenuColors {
pub container: Color,
pub content: Color,
pub icon_content: Color,
pub supporting_content: Color,
pub selected_container: Color,
pub selected_content: Color,
pub state_layer: Color,
pub divider: Color,
}
impl MenuColors {
pub fn resolve(theme: Option<&Theme>, style: MenuColorStyle) -> Self {
let (
surface_container_low,
on_surface,
on_surface_variant,
tertiary_container,
on_tertiary_container,
tertiary,
on_tertiary,
outline_variant,
) = match theme {
Some(theme) => {
let s = theme.scheme();
(
s.surface_container_low,
s.on_surface,
s.on_surface_variant,
s.tertiary_container,
s.on_tertiary_container,
s.tertiary,
s.on_tertiary,
s.outline_variant,
)
}
None => (
FALLBACK_SURFACE_CONTAINER_LOW,
FALLBACK_ON_SURFACE,
FALLBACK_ON_SURFACE_VARIANT,
FALLBACK_TERTIARY_CONTAINER,
FALLBACK_ON_TERTIARY_CONTAINER,
FALLBACK_TERTIARY,
FALLBACK_ON_TERTIARY,
FALLBACK_OUTLINE_VARIANT,
),
};
match style {
MenuColorStyle::Standard => MenuColors {
container: surface_container_low,
content: on_surface,
icon_content: on_surface_variant,
supporting_content: on_surface_variant,
selected_container: tertiary_container,
selected_content: on_tertiary_container,
state_layer: on_surface,
divider: outline_variant,
},
MenuColorStyle::Vibrant => MenuColors {
container: tertiary_container,
content: on_tertiary_container,
icon_content: on_tertiary_container,
supporting_content: on_tertiary_container,
selected_container: tertiary,
selected_content: on_tertiary,
state_layer: on_tertiary_container,
divider: with_alpha(on_tertiary_container, VIBRANT_DIVIDER_ALPHA),
},
}
}
pub fn entry_foreground(&self, enabled: bool, destructive: bool, selected: bool) -> Color {
if !enabled {
return with_alpha(self.content, DISABLED_CONTENT_OPACITY);
}
if destructive {
return self.error();
}
if selected {
self.selected_content
} else {
self.content
}
}
pub fn icon_foreground(&self, enabled: bool, destructive: bool, selected: bool) -> Color {
if !enabled {
return with_alpha(self.icon_content, DISABLED_CONTENT_OPACITY);
}
if destructive {
return self.error();
}
if selected {
self.selected_content
} else {
self.icon_content
}
}
pub fn supporting_foreground(&self, enabled: bool, selected: bool) -> Color {
if !enabled {
return with_alpha(self.content, DISABLED_CONTENT_OPACITY);
}
if selected {
self.selected_content
} else {
self.supporting_content
}
}
fn error(&self) -> Color {
FALLBACK_ERROR
}
}
pub struct MenuView<State: 'static> {
nodes: Vec<MenuNode>,
style: MenuColorStyle,
selected: Option<String>,
open: bool,
placement: OverlayPlacement,
anchor: OverlayAnchor,
on_select: TypedArgCallback<State, MenuSelection>,
on_dismiss: Option<OnDismiss<State>>,
}
type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
pub fn menu<State: 'static, F: Fn(&mut State, MenuSelection) + 'static>(
nodes: Vec<MenuNode>,
on_select: F,
) -> MenuView<State> {
MenuView {
nodes,
style: MenuColorStyle::default(),
selected: None,
open: true,
placement: OverlayPlacement::default()
.align(OverlayAlign::Start)
.offset(OVERLAY_ANCHOR_GAP),
anchor: OverlayAnchor::new(),
on_select: Rc::new(on_select),
on_dismiss: None,
}
}
impl<State: 'static> MenuView<State> {
pub fn anchor(mut self, anchor: &OverlayAnchor) -> Self {
self.anchor = anchor.clone();
self
}
pub fn side(mut self, side: OverlaySide) -> Self {
self.placement.side = side;
self
}
pub fn align(mut self, align: OverlayAlign) -> Self {
self.placement.align = align;
self
}
pub fn offset(mut self, offset: f64) -> Self {
self.placement.offset = offset;
self
}
pub fn color_style(mut self, style: MenuColorStyle) -> Self {
self.style = style;
self
}
pub fn selected(mut self, value: Option<String>) -> Self {
self.selected = value;
self
}
pub fn open(mut self, open: bool) -> Self {
self.open = open;
self
}
pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
self.on_dismiss = Some(Rc::new(on_dismiss));
self
}
fn host(&self) -> AnchoredOverlayView<State> {
let on_select = self.on_select.clone();
let panel = menu_panel(self.nodes.clone(), move |state: &mut State, selection| {
on_select(state, selection)
})
.color_style(self.style)
.selected(self.selected.clone())
.open(self.open);
let mut host = anchored_overlay(panel)
.anchor(&self.anchor)
.placement(self.placement)
.open(self.open);
if let Some(on_dismiss) = self.on_dismiss.clone() {
host = host.on_dismiss(move |state: &mut State| on_dismiss(state));
}
host
}
}
impl<State: 'static> View<State> for MenuView<State> {
type Element = AnchoredOverlayWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> AnchoredOverlayWidget {
View::build(&self.host(), ctx)
}
fn rebuild(
&self,
prev: &Self,
element: &mut AnchoredOverlayWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
View::rebuild(&self.host(), &prev.host(), element, ctx)
}
fn teardown(&self, element: &mut AnchoredOverlayWidget, ctx: &mut BuildCtx<'_>) {
View::teardown(&self.host(), element, ctx);
}
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::{
Color, InputEvent, PaintScene, Point, PointerButton, PointerEvent, PointerPhase, Rect,
Size, text::TextContext,
};
use frust::{Brightness, FrameTime};
use frust_core::RenderRoot;
use std::any::Any;
#[test]
fn standard_resolves_the_reference_s_surface_roles() {
let theme = crate::baseline().with_brightness(Brightness::Light);
let s = theme.scheme();
let colors = MenuColors::resolve(Some(&theme), MenuColorStyle::Standard);
assert_eq!(colors.container, s.surface_container_low);
assert_eq!(colors.content, s.on_surface);
assert_eq!(colors.icon_content, s.on_surface_variant);
assert_eq!(colors.supporting_content, s.on_surface_variant);
assert_eq!(colors.selected_container, s.tertiary_container);
assert_eq!(colors.selected_content, s.on_tertiary_container);
assert_eq!(colors.state_layer, s.on_surface);
assert_eq!(colors.divider, s.outline_variant);
}
#[test]
fn vibrant_promotes_the_whole_surface_to_the_tertiary_family() {
let theme = crate::baseline().with_brightness(Brightness::Light);
let s = theme.scheme();
let colors = MenuColors::resolve(Some(&theme), MenuColorStyle::Vibrant);
assert_eq!(colors.container, s.tertiary_container);
assert_eq!(colors.content, s.on_tertiary_container);
assert_eq!(colors.icon_content, s.on_tertiary_container);
assert_eq!(colors.selected_container, s.tertiary);
assert_eq!(colors.selected_content, s.on_tertiary);
assert_eq!(colors.state_layer, s.on_tertiary_container);
assert_eq!(
colors.divider,
with_alpha(s.on_tertiary_container, VIBRANT_DIVIDER_ALPHA)
);
}
#[test]
fn the_two_styles_never_resolve_to_the_same_surface() {
let theme = crate::baseline();
let standard = MenuColors::resolve(Some(&theme), MenuColorStyle::Standard);
let vibrant = MenuColors::resolve(Some(&theme), MenuColorStyle::Vibrant);
assert_ne!(standard.container, vibrant.container);
assert_ne!(standard.content, vibrant.content);
assert_eq!(MenuColorStyle::default(), MenuColorStyle::Standard);
}
#[test]
fn the_unthemed_fallbacks_are_the_m3_baseline_light_values() {
let light = crate::baseline().with_brightness(Brightness::Light);
for style in [MenuColorStyle::Standard, MenuColorStyle::Vibrant] {
assert_eq!(
MenuColors::resolve(None, style),
MenuColors::resolve(Some(&light), style),
"{style:?}"
);
}
}
#[test]
fn the_colors_follow_a_live_brightness_flip() {
let light = crate::baseline().with_brightness(Brightness::Light);
let dark = crate::baseline().with_brightness(Brightness::Dark);
assert_ne!(
MenuColors::resolve(Some(&light), MenuColorStyle::Standard).container,
MenuColors::resolve(Some(&dark), MenuColorStyle::Standard).container
);
}
#[test]
fn the_foreground_ladder_is_disabled_then_destructive_then_selected() {
let colors = MenuColors::resolve(None, MenuColorStyle::Standard);
assert_eq!(
colors.entry_foreground(false, false, false),
with_alpha(colors.content, DISABLED_CONTENT_OPACITY),
"disabled outranks everything"
);
assert_eq!(
colors.entry_foreground(false, true, true),
with_alpha(colors.content, DISABLED_CONTENT_OPACITY)
);
assert_eq!(
colors.entry_foreground(true, true, true),
FALLBACK_ERROR,
"destructive outranks selected"
);
assert_eq!(
colors.entry_foreground(true, false, true),
colors.selected_content
);
assert_eq!(colors.entry_foreground(true, false, false), colors.content);
assert_eq!(
colors.icon_foreground(true, false, false),
colors.icon_content
);
assert_eq!(
colors.icon_foreground(true, false, true),
colors.selected_content
);
assert_eq!(colors.icon_foreground(true, true, false), FALLBACK_ERROR);
assert_eq!(
colors.supporting_foreground(false, false),
with_alpha(colors.content, DISABLED_CONTENT_OPACITY)
);
assert_eq!(
colors.supporting_foreground(true, false),
colors.supporting_content
);
assert_eq!(
colors.supporting_foreground(true, true),
colors.selected_content
);
}
#[test]
fn a_non_material_theme_still_resolves_every_role() {
let bare = Theme::neutral();
let _ = MenuColors::resolve(Some(&bare), MenuColorStyle::Vibrant);
}
const WINDOW: Size = Size::new(400.0, 600.0);
const ANCHOR: Rect = Rect::new(40.0, 100.0, 160.0, 140.0);
#[derive(Default)]
struct AppState {
dismissed: u32,
selections: Vec<MenuSelection>,
}
#[derive(Default)]
struct Recorder {
rrects: Vec<(Point, Size)>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
fn fill_rounded_rect(&mut self, origin: Point, size: Size, _radius: f64, _color: Color) {
self.rrects.push((origin, size));
}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
}
struct Harness {
root: RenderRoot<AppState, frust::StackView<AppState>>,
state: AppState,
tcx: TextContext,
anchor: OverlayAnchor,
}
impl Harness {
fn new(nodes: Vec<MenuNode>) -> Self {
let anchor = OverlayAnchor::new();
anchor.set(ANCHOR);
let mut h = Harness {
root: RenderRoot::new(),
state: AppState::default(),
tcx: TextContext::new(),
anchor,
};
h.pass(nodes);
h
}
fn pass(&mut self, nodes: Vec<MenuNode>) {
let anchor = self.anchor.clone();
let mut logic = move |_s: &mut AppState| {
frust::stack().child(
menu(nodes.clone(), |s: &mut AppState, selection| {
s.selections.push(selection)
})
.anchor(&anchor)
.on_dismiss(|s: &mut AppState| s.dismissed += 1),
)
};
self.root.rebuild(&mut logic, &mut self.state);
self.root
.layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
}
fn paint(&mut self) -> Recorder {
let mut rec = Recorder::default();
self.root.paint(&mut rec, FrameTime::ZERO);
rec
}
fn pointer(&mut self, phase: PointerPhase, x: f64, y: f64) {
self.root.event(
&mut self.state,
&InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
}),
);
}
fn click(&mut self, x: f64, y: f64) {
self.pointer(PointerPhase::Down, x, y);
self.pointer(PointerPhase::Up, x, y);
}
}
fn first_row_center() -> Point {
Point::new(
ANCHOR.x0 + MENU_MIN_WIDTH / 2.0,
ANCHOR.y1
+ OVERLAY_ANCHOR_GAP
+ MENU_SURFACE_V_PADDING
+ (MENU_ITEM_GAP + MENU_ROW_MIN_HEIGHT) / 2.0,
)
}
#[test]
fn the_menu_places_its_panel_under_the_trigger_at_the_anchor_gap() {
let mut h = Harness::new(vec![menu_entry("Cut").into()]);
let rec = h.paint();
let surface = rec.rrects.first().expect("the menu surface");
assert_eq!(
surface.0,
Point::new(ANCHOR.x0, ANCHOR.y1 + OVERLAY_ANCHOR_GAP),
"the reference's `bottomStart`, at `anchorOffset`"
);
}
#[test]
fn a_press_on_a_row_reports_through_the_host_and_never_dismisses() {
let mut h = Harness::new(vec![menu_entry("Cut").into()]);
let c = first_row_center();
h.click(c.x, c.y);
assert_eq!(h.state.selections.len(), 1);
assert_eq!(h.state.selections[0].label, "Cut");
assert_eq!(
h.state.dismissed, 0,
"a row press is the panel's, not a dismiss"
);
}
#[test]
fn a_press_outside_the_panel_light_dismisses_the_whole_menu() {
let mut h = Harness::new(vec![
menu_submenu("Share", vec![menu_entry("Copy link").into()]).into(),
]);
let c = first_row_center();
h.click(c.x, c.y);
assert!(h.state.selections.is_empty(), "opening is not selecting");
h.pointer(PointerPhase::Down, 5.0, 5.0);
assert_eq!(h.state.dismissed, 1);
assert!(
h.state.selections.is_empty(),
"the dismissing press never reached a row"
);
}
#[test]
fn a_closed_kept_mounted_menu_paints_nothing_once_the_host_settles() {
let anchor = OverlayAnchor::new();
anchor.set(ANCHOR);
let mut root: RenderRoot<AppState, MenuView<AppState>> = RenderRoot::new();
let mut state = AppState::default();
let mut tcx = TextContext::new();
let cell = anchor.clone();
let mut closed = move |_s: &mut AppState| {
menu(vec![menu_entry("Cut").into()], |_s: &mut AppState, _| {})
.anchor(&cell)
.open(false)
};
root.rebuild(&mut closed, &mut state);
root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
let mut rec = Recorder::default();
root.paint(&mut rec, FrameTime::ZERO);
assert!(
rec.rrects.is_empty(),
"a host mounted closed shows no panel at all"
);
}
#[test]
fn the_metrics_are_the_reference_s_menu_theme_defaults() {
assert_eq!(MENU_MIN_WIDTH, 112.0);
assert_eq!(MENU_MAX_WIDTH, 280.0);
assert_eq!(MENU_MAX_HEIGHT, 320.0);
assert_eq!(MENU_ROW_MIN_HEIGHT, 48.0);
assert_eq!(MENU_ICON_SIZE, 24.0);
assert_eq!(MENU_SURFACE_V_PADDING, 8.0);
assert_eq!(MENU_SURFACE_H_PADDING, 8.0);
assert_eq!(MENU_SECTION_GAP, 8.0);
assert_eq!(MENU_ROW_H_PADDING, 12.0);
assert_eq!(MENU_ICON_GAP, 12.0);
assert_eq!(MENU_ITEM_GAP, 4.0);
assert_eq!(MENU_GROUP_LABEL_H_PADDING, 12.0);
assert_eq!(MENU_GROUP_LABEL_V_PADDING, 8.0);
assert_eq!(MENU_CONTAINER_RADIUS, 16.0);
assert_eq!(MENU_ITEM_RADIUS, 12.0);
assert_eq!(MENU_SUBMENU_GAP, 4.0);
assert_eq!(SELECTED_CHECK_SCALE, 0.9);
assert_eq!(DISABLED_CONTENT_OPACITY, 0.38);
}
}