use crate::get_global_color;
use egui::{self, Color32, Context, Id, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2};
#[derive(Clone, Copy, PartialEq)]
pub enum Corner {
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
#[derive(Clone, Copy, PartialEq)]
pub enum FocusState {
None,
ListRoot,
FirstItem,
}
#[derive(Clone, Copy, PartialEq)]
pub enum Positioning {
Absolute,
Fixed,
Document,
Popover,
}
#[derive(Clone, Debug)]
#[derive(Default)]
pub struct MenuStyle {
pub background_color: Option<Color32>,
pub shadow_color: Option<Color32>,
pub surface_tint_color: Option<Color32>,
pub elevation: Option<f32>,
pub padding: Option<f32>,
pub min_width: Option<f32>,
pub max_width: Option<f32>,
pub corner_radius: Option<f32>,
}
impl MenuStyle {
pub fn merge(&self, other: &MenuStyle) -> MenuStyle {
MenuStyle {
background_color: self.background_color.or(other.background_color),
shadow_color: self.shadow_color.or(other.shadow_color),
surface_tint_color: self.surface_tint_color.or(other.surface_tint_color),
elevation: self.elevation.or(other.elevation),
padding: self.padding.or(other.padding),
min_width: self.min_width.or(other.min_width),
max_width: self.max_width.or(other.max_width),
corner_radius: self.corner_radius.or(other.corner_radius),
}
}
fn resolve(&self) -> ResolvedMenuStyle {
ResolvedMenuStyle {
background_color: self
.background_color
.unwrap_or_else(|| get_global_color("surfaceContainer")),
shadow_color: self
.shadow_color
.unwrap_or_else(|| get_global_color("shadow")),
elevation: self.elevation.unwrap_or(3.0),
padding: self.padding.unwrap_or(8.0),
_min_width: self.min_width.unwrap_or(112.0),
max_width: self.max_width.unwrap_or(280.0),
corner_radius: self.corner_radius.unwrap_or(4.0),
}
}
}
struct ResolvedMenuStyle {
background_color: Color32,
shadow_color: Color32,
elevation: f32,
padding: f32,
_min_width: f32,
max_width: f32,
corner_radius: f32,
}
#[derive(Clone, Debug, Default)]
pub struct MenuThemeData {
pub style: Option<MenuStyle>,
}
#[derive(Clone, Debug, Default)]
pub struct MenuBarThemeData {
pub style: Option<MenuStyle>,
}
#[derive(Clone, Debug)]
#[derive(Default)]
pub struct MenuButtonThemeData {
pub foreground_color: Option<Color32>,
pub icon_color: Option<Color32>,
pub disabled_foreground_color: Option<Color32>,
pub disabled_icon_color: Option<Color32>,
pub hover_overlay_opacity: Option<f32>,
pub pressed_overlay_opacity: Option<f32>,
pub text_font: Option<egui::FontId>,
pub min_height: Option<f32>,
pub icon_size: Option<f32>,
pub padding_horizontal: Option<f32>,
}
impl MenuButtonThemeData {
fn resolve(&self) -> ResolvedMenuButtonTheme {
let on_surface = get_global_color("onSurface");
let on_surface_variant = get_global_color("onSurfaceVariant");
let disabled_color = Color32::from_rgba_premultiplied(
on_surface.r(),
on_surface.g(),
on_surface.b(),
97, );
ResolvedMenuButtonTheme {
foreground_color: self.foreground_color.unwrap_or(on_surface),
icon_color: self.icon_color.unwrap_or(on_surface_variant),
disabled_foreground_color: self.disabled_foreground_color.unwrap_or(disabled_color),
disabled_icon_color: self.disabled_icon_color.unwrap_or(disabled_color),
hover_overlay_opacity: self.hover_overlay_opacity.unwrap_or(0.08),
pressed_overlay_opacity: self.pressed_overlay_opacity.unwrap_or(0.10),
text_font: self.text_font.clone().unwrap_or_default(),
min_height: self.min_height.unwrap_or(48.0),
icon_size: self.icon_size.unwrap_or(24.0),
padding_horizontal: self.padding_horizontal.unwrap_or(12.0),
}
}
}
struct ResolvedMenuButtonTheme {
foreground_color: Color32,
icon_color: Color32,
disabled_foreground_color: Color32,
disabled_icon_color: Color32,
hover_overlay_opacity: f32,
pressed_overlay_opacity: f32,
text_font: egui::FontId,
min_height: f32,
icon_size: f32,
padding_horizontal: f32,
}
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
pub struct MaterialMenu<'a> {
id: Id,
open: &'a mut bool,
anchor_rect: Option<Rect>,
items: Vec<MenuItem<'a>>,
anchor_corner: Corner,
menu_corner: Corner,
default_focus: FocusState,
positioning: Positioning,
quick: bool,
has_overflow: bool,
stay_open_on_outside_click: bool,
stay_open_on_focusout: bool,
skip_restore_focus: bool,
x_offset: f32,
y_offset: f32,
no_horizontal_flip: bool,
no_vertical_flip: bool,
typeahead_delay: f32,
list_tab_index: i32,
menu_style: Option<MenuStyle>,
button_theme: Option<MenuButtonThemeData>,
}
pub struct MenuItem<'a> {
text: String,
leading_icon: Option<String>,
trailing_icon: Option<String>,
enabled: bool,
divider_after: bool,
action: Option<Box<dyn Fn() + 'a>>,
}
impl<'a> MaterialMenu<'a> {
pub fn new(id: impl Into<Id>, open: &'a mut bool) -> Self {
Self {
id: id.into(),
open,
anchor_rect: None,
items: Vec::new(),
anchor_corner: Corner::BottomLeft,
menu_corner: Corner::TopLeft,
default_focus: FocusState::None,
positioning: Positioning::Absolute,
quick: false,
has_overflow: false,
stay_open_on_outside_click: false,
stay_open_on_focusout: false,
skip_restore_focus: false,
x_offset: 0.0,
y_offset: 0.0,
no_horizontal_flip: false,
no_vertical_flip: false,
typeahead_delay: 200.0,
list_tab_index: -1,
menu_style: None,
button_theme: None,
}
}
pub fn anchor_rect(mut self, rect: Rect) -> Self {
self.anchor_rect = Some(rect);
self
}
pub fn item(mut self, item: MenuItem<'a>) -> Self {
self.items.push(item);
self
}
pub fn style(mut self, style: MenuStyle) -> Self {
self.menu_style = Some(style);
self
}
pub fn button_theme(mut self, theme: MenuButtonThemeData) -> Self {
self.button_theme = Some(theme);
self
}
pub fn elevation(mut self, elevation: f32) -> Self {
let style = self.menu_style.get_or_insert_with(MenuStyle::default);
style.elevation = Some(elevation);
self
}
pub fn anchor_corner(mut self, corner: Corner) -> Self {
self.anchor_corner = corner;
self
}
pub fn menu_corner(mut self, corner: Corner) -> Self {
self.menu_corner = corner;
self
}
pub fn default_focus(mut self, focus: FocusState) -> Self {
self.default_focus = focus;
self
}
pub fn positioning(mut self, positioning: Positioning) -> Self {
self.positioning = positioning;
self
}
pub fn quick(mut self, quick: bool) -> Self {
self.quick = quick;
self
}
pub fn has_overflow(mut self, has_overflow: bool) -> Self {
self.has_overflow = has_overflow;
self
}
pub fn stay_open_on_outside_click(mut self, stay_open: bool) -> Self {
self.stay_open_on_outside_click = stay_open;
self
}
pub fn stay_open_on_focusout(mut self, stay_open: bool) -> Self {
self.stay_open_on_focusout = stay_open;
self
}
pub fn skip_restore_focus(mut self, skip: bool) -> Self {
self.skip_restore_focus = skip;
self
}
pub fn x_offset(mut self, offset: f32) -> Self {
self.x_offset = offset;
self
}
pub fn y_offset(mut self, offset: f32) -> Self {
self.y_offset = offset;
self
}
pub fn no_horizontal_flip(mut self, no_flip: bool) -> Self {
self.no_horizontal_flip = no_flip;
self
}
pub fn no_vertical_flip(mut self, no_flip: bool) -> Self {
self.no_vertical_flip = no_flip;
self
}
pub fn typeahead_delay(mut self, delay: f32) -> Self {
self.typeahead_delay = delay;
self
}
pub fn list_tab_index(mut self, index: i32) -> Self {
self.list_tab_index = index;
self
}
pub fn show(self, ctx: &Context) {
if !*self.open {
return;
}
let resolved_style = self
.menu_style
.as_ref()
.unwrap_or(&MenuStyle::default())
.resolve();
let resolved_button = self
.button_theme
.as_ref()
.unwrap_or(&MenuButtonThemeData::default())
.resolve();
let stable_id = egui::Id::new(format!("menu_{}", self.id.value()));
let frames_since_opened = ctx.data_mut(|d| {
let last_open_state = d
.get_temp::<bool>(stable_id.with("was_open_last_frame"))
.unwrap_or(false);
let just_opened = !last_open_state && *self.open;
d.insert_temp(stable_id.with("was_open_last_frame"), *self.open);
let frame_count: u32 = if just_opened {
0
} else {
d.get_temp::<u32>(stable_id.with("open_frame_count"))
.unwrap_or(0)
.saturating_add(1)
};
d.insert_temp(stable_id.with("open_frame_count"), frame_count);
frame_count
});
let was_recently_opened = frames_since_opened < 2;
if frames_since_opened == 0 && !self.skip_restore_focus {
ctx.memory_mut(|mem| mem.request_focus(stable_id));
}
let item_height = resolved_button.min_height;
let vertical_padding = resolved_style.padding * 2.0;
let total_height = self.items.len() as f32 * item_height
+ self.items.iter().filter(|item| item.divider_after).count() as f32
+ vertical_padding;
let menu_width = resolved_style.max_width;
let menu_size = Vec2::new(menu_width, total_height);
let position = if let Some(anchor) = self.anchor_rect {
let anchor_point = match self.anchor_corner {
Corner::TopLeft => anchor.min,
Corner::TopRight => Pos2::new(anchor.max.x, anchor.min.y),
Corner::BottomLeft => Pos2::new(anchor.min.x, anchor.max.y),
Corner::BottomRight => anchor.max,
};
let menu_offset = match self.menu_corner {
Corner::TopLeft => Vec2::ZERO,
Corner::TopRight => Vec2::new(-menu_size.x, 0.0),
Corner::BottomLeft => Vec2::new(0.0, -menu_size.y),
Corner::BottomRight => -menu_size,
};
let base_position = anchor_point + menu_offset;
Pos2::new(
base_position.x + self.x_offset,
base_position.y + self.y_offset + 4.0, )
} else {
let screen_rect = ctx.content_rect();
screen_rect.center() - menu_size / 2.0
};
let open_ref = self.open;
let _id = self.id;
let items = self.items;
let stay_open_on_outside_click = self.stay_open_on_outside_click;
let _stay_open_on_focusout = self.stay_open_on_focusout;
let _area_response = egui::Area::new(stable_id)
.fixed_pos(position)
.order(egui::Order::Foreground)
.interactable(true)
.show(ctx, |ui| {
render_menu_content(
ui,
menu_size,
items,
&resolved_style,
&resolved_button,
open_ref,
)
});
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
*open_ref = false;
} else if !stay_open_on_outside_click && !was_recently_opened {
if ctx.input(|i| i.pointer.any_click()) {
let pointer_pos = ctx.input(|i| i.pointer.interact_pos()).unwrap_or_default();
let menu_rect = Rect::from_min_size(position, menu_size);
let mut inside_area = menu_rect;
if let Some(anchor) = self.anchor_rect {
inside_area = inside_area.union(anchor);
}
if !inside_area.contains(pointer_pos) {
*open_ref = false;
}
}
}
}
}
fn render_menu_content<'a>(
ui: &mut Ui,
size: Vec2,
items: Vec<MenuItem<'a>>,
style: &ResolvedMenuStyle,
button_theme: &ResolvedMenuButtonTheme,
open_ref: &'a mut bool,
) -> Response {
let (rect, response) = ui.allocate_exact_size(size, Sense::hover());
let outline_variant = get_global_color("outlineVariant");
let shadow_offset = style.elevation * 2.0;
let shadow_rect = rect.expand(shadow_offset);
let shadow_alpha = ((style.elevation * 10.0) as u8).min(80);
ui.painter().rect_filled(
shadow_rect,
style.corner_radius,
Color32::from_rgba_premultiplied(
style.shadow_color.r(),
style.shadow_color.g(),
style.shadow_color.b(),
shadow_alpha,
),
);
ui.painter()
.rect_filled(rect, style.corner_radius, style.background_color);
ui.painter().rect_stroke(
rect,
style.corner_radius,
Stroke::new(1.0, outline_variant),
egui::epaint::StrokeKind::Outside,
);
let mut current_y = rect.min.y + style.padding;
let mut pending_actions = Vec::new();
let mut should_close = false;
for (index, item) in items.into_iter().enumerate() {
let item_rect = Rect::from_min_size(
Pos2::new(rect.min.x + 8.0, current_y),
Vec2::new(rect.width() - 16.0, button_theme.min_height),
);
let item_response = ui.interact(
item_rect,
egui::Id::new(format!("menu_item_{}_{}", rect.min.x as i32, index)),
Sense::click(),
);
if item.enabled {
let overlay_opacity = if item_response.is_pointer_button_down_on() {
button_theme.pressed_overlay_opacity
} else if item_response.hovered() {
button_theme.hover_overlay_opacity
} else {
0.0
};
if overlay_opacity > 0.0 {
let on_surface = button_theme.foreground_color;
let overlay_alpha = (overlay_opacity * 255.0) as u8;
let hover_color = Color32::from_rgba_premultiplied(
on_surface.r(),
on_surface.g(),
on_surface.b(),
overlay_alpha,
);
ui.painter().rect_filled(item_rect, 4.0, hover_color);
}
}
if item_response.clicked() && item.enabled {
if let Some(action) = item.action {
pending_actions.push(action);
should_close = true;
}
}
let mut content_x = item_rect.min.x + button_theme.padding_horizontal;
let content_y = item_rect.center().y;
if let Some(_icon) = &item.leading_icon {
let half_icon = button_theme.icon_size / 2.0;
let icon_rect = Rect::from_min_size(
Pos2::new(content_x, content_y - half_icon),
Vec2::splat(button_theme.icon_size),
);
let icon_color = if item.enabled {
button_theme.icon_color
} else {
button_theme.disabled_icon_color
};
ui.painter()
.circle_filled(icon_rect.center(), half_icon / 3.0 * 2.0, icon_color);
content_x += button_theme.icon_size + button_theme.padding_horizontal;
}
let text_color = if item.enabled {
button_theme.foreground_color
} else {
button_theme.disabled_foreground_color
};
let text_pos = Pos2::new(content_x, content_y);
ui.painter().text(
text_pos,
egui::Align2::LEFT_CENTER,
&item.text,
button_theme.text_font.clone(),
text_color,
);
if let Some(_icon) = &item.trailing_icon {
let half_icon = button_theme.icon_size / 2.0;
let icon_rect = Rect::from_min_size(
Pos2::new(
item_rect.max.x - button_theme.padding_horizontal - button_theme.icon_size,
content_y - half_icon,
),
Vec2::splat(button_theme.icon_size),
);
let icon_color = if item.enabled {
button_theme.icon_color
} else {
button_theme.disabled_icon_color
};
ui.painter()
.circle_filled(icon_rect.center(), half_icon / 3.0 * 2.0, icon_color);
}
current_y += button_theme.min_height;
if item.divider_after {
let divider_y = current_y;
let divider_start = Pos2::new(rect.min.x + 12.0, divider_y);
let divider_end = Pos2::new(rect.max.x - 12.0, divider_y);
ui.painter().line_segment(
[divider_start, divider_end],
Stroke::new(1.0, outline_variant),
);
current_y += 1.0;
}
}
for action in pending_actions {
action();
}
if should_close {
*open_ref = false;
}
response
}
impl<'a> MenuItem<'a> {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
leading_icon: None,
trailing_icon: None,
enabled: true,
divider_after: false,
action: None,
}
}
pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
self.leading_icon = Some(icon.into());
self
}
pub fn trailing_icon(mut self, icon: impl Into<String>) -> Self {
self.trailing_icon = Some(icon.into());
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn divider_after(mut self, divider: bool) -> Self {
self.divider_after = divider;
self
}
pub fn on_click<F>(mut self, f: F) -> Self
where
F: Fn() + 'a,
{
self.action = Some(Box::new(f));
self
}
}
pub fn menu(id: impl Into<egui::Id>, open: &mut bool) -> MaterialMenu<'_> {
MaterialMenu::new(id, open)
}
pub fn menu_item(text: impl Into<String>) -> MenuItem<'static> {
MenuItem::new(text)
}