use std::hash::Hash;
use egui::{
CornerRadius, Id, Popup, PopupCloseBehavior, Pos2, Response, Sense, Ui, Vec2, Widget,
WidgetInfo, WidgetText, WidgetType,
};
use crate::theme::{with_alpha, Theme};
#[derive(Debug, Clone)]
#[must_use = "Call `.show_below(&trigger, |ui| ...)` to render the menu."]
pub struct Menu {
id_salt: Id,
min_width: f32,
}
impl Menu {
pub fn new(id_salt: impl Hash) -> Self {
Self {
id_salt: Id::new(("elegance::menu", Id::new(id_salt))),
min_width: 180.0,
}
}
pub fn min_width(mut self, min_width: f32) -> Self {
self.min_width = min_width;
self
}
pub fn show_below<R>(
self,
trigger: &Response,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
let popup_id = Id::new(self.id_salt);
Popup::menu(trigger)
.id(popup_id)
.close_behavior(PopupCloseBehavior::CloseOnClick)
.show(|ui| {
ui.set_min_width(self.min_width);
ui.spacing_mut().item_spacing.y = 2.0;
add_contents(ui)
})
.map(|r| r.inner)
}
}
#[must_use = "Add with `ui.add(...)`."]
pub struct MenuItem {
label: WidgetText,
shortcut: Option<String>,
danger: bool,
enabled: bool,
leading: Option<Leading>,
submenu_arrow: bool,
}
#[derive(Clone)]
enum Leading {
Icon(WidgetText),
Checked(bool),
Radio(bool),
}
impl std::fmt::Debug for MenuItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MenuItem")
.field("label", &self.label.text())
.field("shortcut", &self.shortcut)
.field("danger", &self.danger)
.field("enabled", &self.enabled)
.field("leading", &self.leading.is_some())
.finish()
}
}
impl MenuItem {
pub fn new(label: impl Into<WidgetText>) -> Self {
Self {
label: label.into(),
shortcut: None,
danger: false,
enabled: true,
leading: None,
submenu_arrow: false,
}
}
#[doc(hidden)]
pub fn with_submenu_arrow(mut self) -> Self {
self.submenu_arrow = true;
self
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn danger(mut self) -> Self {
self.danger = true;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn icon(mut self, icon: impl Into<WidgetText>) -> Self {
self.leading = Some(Leading::Icon(icon.into()));
self
}
pub fn checked(mut self, on: bool) -> Self {
self.leading = Some(Leading::Checked(on));
self
}
pub fn radio(mut self, on: bool) -> Self {
self.leading = Some(Leading::Radio(on));
self
}
}
impl Widget for MenuItem {
fn ui(self, ui: &mut Ui) -> Response {
let theme = Theme::current(ui.ctx());
let p = &theme.palette;
let t = &theme.typography;
let pad_x = 10.0;
let pad_y = 6.0;
let gap_x = 16.0;
let gutter_w = 16.0; let gutter_gap = 8.0;
let label_color = if !self.enabled {
p.text_faint
} else if self.danger {
p.danger
} else {
p.text
};
let label_galley =
crate::theme::placeholder_galley(ui, self.label.text(), t.body, false, f32::INFINITY);
let shortcut_galley = self
.shortcut
.as_deref()
.map(|s| crate::theme::placeholder_galley(ui, s, t.small, false, f32::INFINITY));
let submenu_arrow_galley = if self.submenu_arrow {
Some(crate::theme::placeholder_galley(
ui,
"\u{203A}",
24.0,
false,
f32::INFINITY,
))
} else {
None
};
let leading_glyph_galley = match &self.leading {
Some(Leading::Icon(icon)) => Some(crate::theme::placeholder_galley(
ui,
icon.text(),
t.body,
false,
f32::INFINITY,
)),
Some(Leading::Checked(true)) => Some(crate::theme::placeholder_galley(
ui,
"\u{2713}",
t.body,
true,
f32::INFINITY,
)),
Some(Leading::Radio(true)) => Some(crate::theme::placeholder_galley(
ui,
"\u{2022}",
t.body,
true,
f32::INFINITY,
)),
Some(Leading::Checked(false)) | Some(Leading::Radio(false)) => None,
None => None,
};
let leading_offset = if self.leading.is_some() {
gutter_w + gutter_gap
} else {
0.0
};
let trailing_w = shortcut_galley
.as_ref()
.map_or(0.0, |g| g.size().x + gap_x)
.max(
submenu_arrow_galley
.as_ref()
.map_or(0.0, |g| g.size().x + gap_x),
);
let content_w = leading_offset + label_galley.size().x + trailing_w;
let desired = Vec2::new(
content_w + pad_x * 2.0,
label_galley.size().y.max(t.body) + pad_y * 2.0,
);
let sense = if self.enabled {
Sense::click()
} else {
Sense::hover()
};
let (rect, response) = ui.allocate_at_least(desired, sense);
if ui.is_rect_visible(rect) {
let is_hovered = response.hovered() && self.enabled;
if is_hovered {
let bg = if self.danger {
with_alpha(p.red, 40)
} else {
with_alpha(p.sky, 28)
};
let radius = CornerRadius::same((theme.control_radius as u8).saturating_sub(2));
ui.painter().rect_filled(rect, radius, bg);
}
if let Some(glyph) = leading_glyph_galley {
let slot_x = rect.min.x + pad_x;
let glyph_color = match &self.leading {
Some(Leading::Checked(true)) | Some(Leading::Radio(true)) => p.sky,
_ if !self.enabled => p.text_faint,
_ => p.text_muted,
};
let pos = Pos2::new(
slot_x + (gutter_w - glyph.size().x) * 0.5,
rect.center().y - glyph.size().y * 0.5,
);
ui.painter().galley(pos, glyph, glyph_color);
}
let label_pos = Pos2::new(
rect.min.x + pad_x + leading_offset,
rect.center().y - label_galley.size().y * 0.5,
);
ui.painter().galley(label_pos, label_galley, label_color);
if let Some(galley) = shortcut_galley {
let pos = Pos2::new(
rect.max.x - pad_x - galley.size().x,
rect.center().y - galley.size().y * 0.5,
);
let color = if !self.enabled {
p.text_faint
} else if self.danger {
with_alpha(p.danger, 200)
} else {
p.text_muted
};
ui.painter().galley(pos, galley, color);
}
if let Some(galley) = submenu_arrow_galley {
let pos = Pos2::new(
rect.max.x - pad_x - galley.size().x,
rect.center().y - galley.size().y * 0.5,
);
let color = if !self.enabled {
p.text_faint
} else {
p.text_muted
};
ui.painter().galley(pos, galley, color);
}
}
response.widget_info(|| match &self.leading {
Some(Leading::Checked(on)) => {
WidgetInfo::selected(WidgetType::Checkbox, self.enabled, *on, self.label.text())
}
Some(Leading::Radio(on)) => WidgetInfo::selected(
WidgetType::RadioButton,
self.enabled,
*on,
self.label.text(),
),
_ => WidgetInfo::labeled(WidgetType::Button, self.enabled, self.label.text()),
});
response
}
}
#[must_use = "Call `.show(ui, |ui| ...)` to render the submenu trigger and flyout."]
pub struct SubMenuItem {
label: WidgetText,
icon: Option<WidgetText>,
enabled: bool,
}
impl std::fmt::Debug for SubMenuItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SubMenuItem")
.field("label", &self.label.text())
.field("icon", &self.icon.is_some())
.field("enabled", &self.enabled)
.finish()
}
}
impl SubMenuItem {
pub fn new(label: impl Into<WidgetText>) -> Self {
Self {
label: label.into(),
icon: None,
enabled: true,
}
}
pub fn icon(mut self, icon: impl Into<WidgetText>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn show<R>(self, ui: &mut Ui, body: impl FnOnce(&mut Ui) -> R) -> Option<R> {
let mut item = MenuItem::new(self.label)
.enabled(self.enabled)
.with_submenu_arrow();
if let Some(icon) = self.icon {
item = item.icon(icon);
}
let response = ui.add(item);
sub_menu_show(ui, &response, body)
}
}
fn sub_menu_show<R>(
ui: &Ui,
button_response: &Response,
content: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
use egui::containers::menu::{MenuConfig, MenuState};
use egui::{
emath::{Align, RectAlign},
pos2, Frame, Layout, Margin, PointerButton, Popup, PopupCloseBehavior, Rect, UiKind,
UiStackInfo,
};
const GAP: f32 = 0.0;
let frame = Frame::menu(ui.style()).inner_margin(Margin::same(4));
let id = button_response.id.with("submenu");
let (open_item, menu_id) = MenuState::from_ui(ui, |state, stack| (state.open_item, stack.id));
let menu_config = MenuConfig::find(ui);
let menu_root_response = ui
.ctx()
.read_response(menu_id)
.expect("submenu must be inside a menu");
let hover_pos = ui.ctx().pointer_hover_pos();
let menu_rect = menu_root_response.rect - frame.total_margin();
let is_hovering_menu = hover_pos.is_some_and(|pos| {
ui.ctx().layer_id_at(pos) == Some(menu_root_response.layer_id) && menu_rect.contains(pos)
});
let is_any_open = open_item.is_some();
let mut is_open = open_item == Some(id);
let was_open = is_open;
let mut set_open: Option<bool> = None;
let button_rect = button_response
.rect
.expand2(ui.style().spacing.item_spacing / 2.0);
let is_hovered = hover_pos.is_some_and(|pos| button_rect.contains(pos));
let clicked = button_response.clicked();
let clicked_by_pointer = button_response.clicked_by(PointerButton::Primary);
let clicked_by_keyboard_or_access = clicked && !clicked_by_pointer;
if ui.is_enabled() && is_open && clicked_by_keyboard_or_access {
set_open = Some(false);
is_open = false;
}
let should_open = ui.is_enabled() && ((!was_open && clicked) || (is_hovered && !is_any_open));
if should_open {
set_open = Some(true);
is_open = true;
MenuState::from_id(ui.ctx(), menu_id, |state| {
state.open_item = None;
});
}
const LEFT_INSET: f32 = 24.0;
let mut response = button_response.clone();
let bottom = button_response.rect.bottom();
let left = (button_response.rect.left() + LEFT_INSET).min(button_response.rect.right());
response.interact_rect = Rect::from_min_max(
pos2(left, bottom),
pos2(button_response.rect.right(), bottom),
);
let popup_response = Popup::from_response(&response)
.id(id)
.open(is_open)
.align(RectAlign::BOTTOM_START)
.align_alternatives(&[])
.layout(Layout::top_down_justified(Align::Min))
.gap(GAP)
.style(menu_config.style.clone())
.frame(frame)
.close_behavior(PopupCloseBehavior::IgnoreClicks)
.info(
UiStackInfo::new(UiKind::Menu)
.with_tag_value(MenuConfig::MENU_CONFIG_TAG, menu_config.clone()),
)
.show(|ui| {
if button_response.clicked() || button_response.is_pointer_button_down_on() {
ui.ctx().move_to_top(ui.layer_id());
}
content(ui)
});
if let Some(popup_response) = &popup_response {
let is_deepest_submenu = MenuState::is_deepest_open_sub_menu(ui.ctx(), id);
let clicked_outside = is_deepest_submenu
&& popup_response.response.clicked_elsewhere()
&& menu_root_response.clicked_elsewhere();
let submenu_button_clicked = button_response.clicked();
let clicked_inside = is_deepest_submenu
&& !submenu_button_clicked
&& response.ctx.input(|i| i.pointer.any_click())
&& hover_pos.is_some_and(|pos| popup_response.response.interact_rect.contains(pos));
let click_close = match menu_config.close_behavior {
PopupCloseBehavior::CloseOnClick => clicked_outside || clicked_inside,
PopupCloseBehavior::CloseOnClickOutside => clicked_outside,
PopupCloseBehavior::IgnoreClicks => false,
};
if click_close {
set_open = Some(false);
}
let is_moving_towards_rect = ui.input(|i| {
i.pointer
.is_moving_towards_rect(&popup_response.response.rect)
});
if is_moving_towards_rect {
ui.ctx().request_repaint();
}
let hovering_other_menu_entry = is_open
&& !is_hovered
&& !popup_response.response.contains_pointer()
&& !is_moving_towards_rect
&& is_hovering_menu;
if hovering_other_menu_entry {
set_open = Some(false);
}
}
if let Some(open) = set_open {
MenuState::from_id(ui.ctx(), menu_id, |state| {
state.open_item = open.then_some(id);
});
}
if is_open {
MenuState::mark_shown(ui.ctx(), id);
}
popup_response.map(|r| r.inner)
}
#[must_use = "Add with `ui.add(...)`."]
pub struct MenuSection {
label: WidgetText,
}
impl std::fmt::Debug for MenuSection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MenuSection")
.field("label", &self.label.text())
.finish()
}
}
impl MenuSection {
pub fn new(label: impl Into<WidgetText>) -> Self {
Self {
label: label.into(),
}
}
}
impl Widget for MenuSection {
fn ui(self, ui: &mut Ui) -> Response {
let theme = Theme::current(ui.ctx());
let p = &theme.palette;
let t = &theme.typography;
let pad_x = 10.0;
let pad_top = 6.0;
let pad_bottom = 2.0;
let text = self.label.text().to_uppercase();
let galley = crate::theme::placeholder_galley(ui, &text, t.small, false, f32::INFINITY);
let desired = Vec2::new(
galley.size().x + pad_x * 2.0,
galley.size().y + pad_top + pad_bottom,
);
let (rect, response) = ui.allocate_at_least(desired, Sense::hover());
if ui.is_rect_visible(rect) {
let pos = Pos2::new(rect.min.x + pad_x, rect.min.y + pad_top);
ui.painter().galley(pos, galley, p.text_faint);
}
response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &text));
response
}
}