use egui::{
Align, Color32, Context, CornerRadius, Frame, Id, ImageSource, Layout, Margin, PointerButton,
Popup, PopupCloseBehavior, Pos2, Rect, Response, Role, Sense, SetOpenCommand, Stroke, Ui,
UiBuilder, Vec2, ViewportCommand, WidgetInfo, WidgetText, emath::RectAlign,
};
use crate::theme::{Accent, Theme, mix, with_alpha};
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;
const TRAILING_STATUS_GAP: f32 = 8.0;
#[derive(Debug, Clone)]
struct StatusContent {
text: WidgetText,
dot: Option<Color32>,
}
#[derive(Debug, Clone)]
pub enum BrandLogo {
None,
Square(Accent),
Glyph(String),
Image(ImageSource<'static>),
}
impl Default for BrandLogo {
fn default() -> Self {
Self::Square(Accent::Sky)
}
}
#[derive(Debug, Clone)]
struct Brand {
text: Option<WidgetText>,
logo: Option<BrandLogo>,
}
#[derive(Debug, Clone)]
#[must_use = "Call `.show(ui, |bar| ...)` to render the menu bar."]
pub struct MenuBar {
id_salt: egui::IdSalt,
brand: Option<Brand>,
status: Option<StatusContent>,
title_bar: bool,
}
impl MenuBar {
pub fn new(id_salt: impl crate::IdSalt) -> Self {
Self {
id_salt: egui::IdSalt::new(("elegance::menu_bar", id_salt)),
brand: None,
status: None,
title_bar: false,
}
}
#[inline]
pub fn brand(mut self, text: impl Into<WidgetText>) -> Self {
let logo = self.brand.and_then(|b| b.logo);
self.brand = Some(Brand {
text: Some(text.into()),
logo,
});
self
}
#[inline]
pub fn brand_logo(mut self, logo: BrandLogo) -> Self {
let text = self.brand.and_then(|b| b.text);
self.brand = Some(Brand {
text,
logo: Some(logo),
});
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
}
#[inline]
pub fn title_bar(mut self, title_bar: bool) -> Self {
self.title_bar = title_bar;
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 base_id = ui.scope_id().with_salt(self.id_salt);
let state_id = base_id.with("__state");
let press_id = base_id.with("__title_bar_press");
let prev_state: MenuBarFrameState = ui
.ctx()
.data(|d| d.get_temp::<MenuBarFrameState>(state_id))
.unwrap_or_default();
if prev_state.any_open
&& 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 strip_sense = if self.title_bar {
Sense::CLICK
} else {
Sense::hover()
};
let strip = ui.scope_builder(UiBuilder::new().sense(strip_sense), |ui| {
frame
.show(ui, |ui| {
ui.horizontal(|ui| {
let item_spacing_x = ui.spacing().item_spacing.x;
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);
}
let mut bar = MenuBarUi {
ui,
base_id,
next_idx: 0,
any_open_prev: prev_state.any_open,
any_open_now: false,
triggers: Vec::with_capacity(prev_state.triggers.len()),
status: self.status.as_ref(),
item_spacing_x,
trailing_shown: false,
};
let r = body(&mut bar);
let any_open_now = bar.any_open_now;
let triggers = std::mem::take(&mut bar.triggers);
if !bar.trailing_shown
&& let Some(status) = bar.status
{
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
})
.inner
});
if self.title_bar {
move_window_from_strip(ui.ctx(), &strip.response, press_id);
}
let strip_rect = strip.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),
);
strip.inner
}
}
fn move_window_from_strip(ctx: &Context, strip: &Response, press_id: Id) {
let (pressed, dragging) = ctx.input(|i| {
let pointer = &i.pointer;
(
pointer.primary_pressed(),
pointer.primary_down() && pointer.is_decidedly_dragging(),
)
});
if pressed {
let on_empty = ctx.interaction_snapshot(|s| s.hovered.contains(&strip.id));
ctx.data_mut(|d| d.insert_temp(press_id, on_empty));
}
if dragging && ctx.data_mut(|d| d.remove_temp::<bool>(press_id)) == Some(true) {
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
}
if strip.double_clicked_by(PointerButton::Primary) {
let maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized));
}
}
#[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)>,
status: Option<&'u StatusContent>,
item_spacing_x: f32,
trailing_shown: bool,
}
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)
.field("trailing_shown", &self.trailing_shown)
.finish()
}
}
impl<'u> MenuBarUi<'u> {
#[inline]
pub fn ui(&mut self) -> &mut Ui {
self.ui
}
pub fn trailing<R>(&mut self, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
debug_assert!(
!self.trailing_shown,
"MenuBarUi::trailing called more than once; add all trailing content in one call"
);
self.trailing_shown = true;
let status = self.status;
let item_spacing_x = self.item_spacing_x;
self.ui
.with_layout(Layout::right_to_left(Align::Center), |ui| {
let inner = ui
.scope(|ui| {
ui.spacing_mut().item_spacing.x = item_spacing_x;
add_contents(ui)
})
.inner;
if let Some(status) = status {
ui.add_space(TRAILING_STATUS_GAP);
paint_status(ui, &Theme::current(ui.ctx()), status);
}
inner
})
.inner
}
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> {
debug_assert!(
!self.trailing_shown,
"MenuBarUi menu declared after MenuBarUi::trailing; declare every menu first"
);
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(Role::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, brand: &Brand) {
let p = &theme.palette;
let t = &theme.typography;
let effective_logo = match (&brand.logo, &brand.text) {
(Some(logo), _) => logo.clone(),
(None, Some(_)) => BrandLogo::default(),
(None, None) => unreachable!("Brand requires at least one of text or logo"),
};
let logo_painted = paint_brand_logo(ui, theme, &effective_logo);
if logo_painted && brand.text.is_some() {
ui.add_space(8.0);
}
if let Some(text) = brand.text.as_ref() {
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);
}
if logo_painted || brand.text.is_some() {
ui.add_space(14.0);
}
}
fn paint_brand_logo(ui: &mut Ui, theme: &Theme, logo: &BrandLogo) -> bool {
let p = &theme.palette;
let size = Vec2::splat(BRAND_LOGO_SIZE);
match logo {
BrandLogo::None => false,
BrandLogo::Square(accent) => {
let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
ui.painter()
.rect_filled(rect, CornerRadius::same(3), p.accent_fill(*accent));
true
}
BrandLogo::Glyph(glyph) => {
let galley =
crate::theme::placeholder_galley(ui, glyph, BRAND_LOGO_SIZE, true, f32::INFINITY);
let slot = Vec2::new(
galley.size().x.max(BRAND_LOGO_SIZE),
galley.size().y.max(BRAND_LOGO_SIZE),
);
let (rect, _) = ui.allocate_exact_size(slot, Sense::hover());
let pos = Pos2::new(
rect.center().x - galley.size().x * 0.5,
rect.center().y - galley.size().y * 0.5,
);
ui.painter().galley(pos, galley, p.text);
true
}
BrandLogo::Image(src) => {
let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
egui::Image::new(src.clone())
.fit_to_exact_size(size)
.paint_at(ui, rect);
true
}
}
}
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);
}
}