use std::hash::Hash;
use egui::{
emath::RectAlign, Align, Color32, CornerRadius, Frame, Id, Layout, Margin, Popup,
PopupCloseBehavior, Pos2, Rect, Sense, SetOpenCommand, Stroke, Ui, Vec2, WidgetInfo,
WidgetText, WidgetType,
};
use crate::theme::{mix, with_alpha, Accent, Theme};
const STRIP_PAD_Y: f32 = 4.0;
const STRIP_PAD_X: f32 = 6.0;
const TRIGGER_PAD_X: f32 = 10.0;
const TRIGGER_PAD_Y: f32 = 5.0;
const BRAND_LOGO_SIZE: f32 = 14.0;
#[derive(Debug, Clone)]
struct StatusContent {
text: WidgetText,
dot: Option<Color32>,
}
#[derive(Debug, Clone)]
#[must_use = "Call `.show(ui, |bar| ...)` to render the menu bar."]
pub struct MenuBar {
id_salt: Id,
brand: Option<WidgetText>,
status: Option<StatusContent>,
}
impl MenuBar {
pub fn new(id_salt: impl Hash) -> Self {
Self {
id_salt: Id::new(("elegance::menu_bar", Id::new(id_salt))),
brand: None,
status: None,
}
}
#[inline]
pub fn brand(mut self, text: impl Into<WidgetText>) -> Self {
self.brand = Some(text.into());
self
}
#[inline]
pub fn status(mut self, text: impl Into<WidgetText>) -> Self {
self.status = Some(StatusContent {
text: text.into(),
dot: None,
});
self
}
#[inline]
pub fn status_with_dot(mut self, text: impl Into<WidgetText>, dot: Color32) -> Self {
self.status = Some(StatusContent {
text: text.into(),
dot: Some(dot),
});
self
}
pub fn show<R>(self, ui: &mut Ui, body: impl FnOnce(&mut MenuBarUi<'_>) -> R) -> R {
let theme = Theme::current(ui.ctx());
let p = &theme.palette;
let menubar_fill = mix(p.bg, p.card, 0.45);
let state_id = self.id_salt.with("__state");
let prev_state: MenuBarFrameState = ui
.ctx()
.data(|d| d.get_temp::<MenuBarFrameState>(state_id))
.unwrap_or_default();
if prev_state.any_open {
if let Some(pointer) = ui.ctx().pointer_hover_pos() {
let open_idx = prev_state
.triggers
.iter()
.position(|(id, _)| Popup::is_id_open(ui.ctx(), *id));
if let Some(open_idx) = open_idx {
let on_sibling = prev_state
.triggers
.iter()
.enumerate()
.any(|(i, (_, rect))| i != open_idx && rect.contains(pointer));
if on_sibling {
Popup::close_id(ui.ctx(), prev_state.triggers[open_idx].0);
}
}
}
}
let frame = Frame::new()
.fill(menubar_fill)
.inner_margin(Margin::symmetric(STRIP_PAD_X as i8, STRIP_PAD_Y as i8));
let outer = frame.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.set_min_height(theme.typography.body + TRIGGER_PAD_Y * 2.0);
if let Some(brand) = self.brand.as_ref() {
paint_brand(ui, &theme, brand.clone());
}
let mut bar = MenuBarUi {
ui,
base_id: self.id_salt,
next_idx: 0,
any_open_prev: prev_state.any_open,
any_open_now: false,
triggers: Vec::with_capacity(prev_state.triggers.len()),
};
let r = body(&mut bar);
let any_open_now = bar.any_open_now;
let triggers = std::mem::take(&mut bar.triggers);
if let Some(status) = self.status.as_ref() {
bar.ui
.with_layout(Layout::right_to_left(Align::Center), |ui| {
paint_status(ui, &theme, status);
});
}
bar.ui.ctx().data_mut(|d| {
d.insert_temp(
state_id,
MenuBarFrameState {
triggers,
any_open: any_open_now,
},
)
});
r
})
.inner
});
let strip_rect = outer.response.rect;
ui.painter().line_segment(
[
Pos2::new(strip_rect.min.x, strip_rect.max.y - 0.5),
Pos2::new(strip_rect.max.x, strip_rect.max.y - 0.5),
],
Stroke::new(1.0, p.border),
);
outer.inner
}
}
#[derive(Clone, Default, Debug)]
struct MenuBarFrameState {
triggers: Vec<(Id, Rect)>,
any_open: bool,
}
pub struct MenuBarUi<'u> {
ui: &'u mut Ui,
base_id: Id,
next_idx: usize,
any_open_prev: bool,
any_open_now: bool,
triggers: Vec<(Id, Rect)>,
}
impl<'u> std::fmt::Debug for MenuBarUi<'u> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MenuBarUi")
.field("base_id", &self.base_id)
.field("next_idx", &self.next_idx)
.field("any_open_prev", &self.any_open_prev)
.field("any_open_now", &self.any_open_now)
.finish()
}
}
impl<'u> MenuBarUi<'u> {
pub fn menu<R>(
&mut self,
label: impl Into<WidgetText>,
body: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
self.menu_inner(label, PopupCloseBehavior::CloseOnClick, body)
}
pub fn menu_keep_open<R>(
&mut self,
label: impl Into<WidgetText>,
body: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
self.menu_inner(label, PopupCloseBehavior::CloseOnClickOutside, body)
}
fn menu_inner<R>(
&mut self,
label: impl Into<WidgetText>,
close_behavior: PopupCloseBehavior,
body: impl FnOnce(&mut Ui) -> R,
) -> Option<R> {
let label: WidgetText = label.into();
let theme = Theme::current(self.ui.ctx());
let p = &theme.palette;
let t = &theme.typography;
let idx = self.next_idx;
self.next_idx += 1;
let popup_id = self.base_id.with("__menu").with(idx);
let galley =
crate::theme::placeholder_galley(self.ui, label.text(), t.body, false, f32::INFINITY);
let trigger_size = Vec2::new(
galley.size().x + TRIGGER_PAD_X * 2.0,
galley.size().y + TRIGGER_PAD_Y * 2.0,
);
let (rect, response) = self.ui.allocate_exact_size(trigger_size, Sense::click());
self.triggers.push((popup_id, rect));
let was_open = Popup::is_id_open(self.ui.ctx(), popup_id);
let hovered = response.hovered();
let clicked = response.clicked();
let intent: Option<SetOpenCommand> = if clicked {
Some(SetOpenCommand::Bool(!was_open))
} else if self.any_open_prev && hovered && !was_open {
Some(SetOpenCommand::Bool(true))
} else {
None
};
let will_be_open = matches!(intent, Some(SetOpenCommand::Bool(true)))
|| (was_open && !matches!(intent, Some(SetOpenCommand::Bool(false))));
self.any_open_now |= will_be_open;
if self.ui.is_rect_visible(rect) {
let bg = if will_be_open {
p.card
} else if hovered {
with_alpha(p.text, 14)
} else {
Color32::TRANSPARENT
};
if bg.a() > 0 {
self.ui.painter().rect_filled(rect, CornerRadius::ZERO, bg);
}
let text_color = if will_be_open || hovered {
p.text
} else {
p.text_muted
};
let pos = Pos2::new(
rect.min.x + TRIGGER_PAD_X,
rect.center().y - galley.size().y * 0.5,
);
self.ui.painter().galley(pos, galley, text_color);
}
let r = theme.card_radius as u8;
let frame = Frame::new()
.fill(p.card)
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius {
nw: 0,
ne: r,
sw: r,
se: r,
})
.inner_margin(Margin::same(4));
let label_text = label.text().to_string();
response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label_text));
let result = Popup::menu(&response)
.id(popup_id)
.open_memory(intent)
.align(RectAlign::BOTTOM_START)
.gap(0.0)
.frame(frame)
.close_behavior(close_behavior)
.show(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
body(ui)
});
result.map(|r| r.inner)
}
}
fn paint_brand(ui: &mut Ui, theme: &Theme, text: WidgetText) {
let p = &theme.palette;
let t = &theme.typography;
let logo_size = Vec2::splat(BRAND_LOGO_SIZE);
let (logo_rect, _) = ui.allocate_exact_size(logo_size, Sense::hover());
ui.painter()
.rect_filled(logo_rect, CornerRadius::same(3), p.accent_fill(Accent::Sky));
ui.add_space(8.0);
let galley = crate::theme::placeholder_galley(ui, text.text(), t.body, true, f32::INFINITY);
let label_size = Vec2::new(galley.size().x, galley.size().y + 4.0);
let (rect, _) = ui.allocate_exact_size(label_size, Sense::hover());
let pos = Pos2::new(rect.min.x, rect.center().y - galley.size().y * 0.5);
ui.painter().galley(pos, galley, p.text);
ui.add_space(14.0);
}
fn paint_status(ui: &mut Ui, theme: &Theme, status: &StatusContent) {
let p = &theme.palette;
let t = &theme.typography;
ui.add_space(4.0);
let galley =
crate::theme::placeholder_galley(ui, status.text.text(), t.small, false, f32::INFINITY);
let label_size = Vec2::new(galley.size().x, galley.size().y + 4.0);
let (rect, _) = ui.allocate_exact_size(label_size, Sense::hover());
let pos = Pos2::new(rect.min.x, rect.center().y - galley.size().y * 0.5);
ui.painter().galley(pos, galley, p.text_faint);
if let Some(color) = status.dot {
ui.add_space(6.0);
let (dot_rect, _) = ui.allocate_exact_size(Vec2::splat(7.0), Sense::hover());
ui.painter().circle_filled(dot_rect.center(), 3.5, color);
}
}