use egui::{
Align, Align2, Area, Color32, Context, CornerRadius, FontId, Frame, Id, Key, Layout, Margin,
Order, Pos2, Rect, Response, RichText, Sense, Shape, Stroke, Ui, Vec2, WidgetInfo, WidgetText,
WidgetType, accesskit, vec2,
};
use crate::Button;
use zeus_theme::Theme;
type UiFn<'a> = Box<dyn FnOnce(&mut Ui) + 'a>;
#[must_use = "Call `.show(ctx, |ui| { ... })` to render the modal."]
pub struct Modal<'a> {
id_salt: Id,
heading: Option<WidgetText>,
subtitle: Option<WidgetText>,
header_icon: Option<WidgetText>,
backdrop_order: Order,
content_order: Order,
open: &'a mut bool,
max_width: f32,
closable: bool,
close_on_backdrop: bool,
close_on_escape: bool,
alert: bool,
footer: Option<UiFn<'a>>,
footer_left: Option<UiFn<'a>>,
}
impl<'a> std::fmt::Debug for Modal<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Modal")
.field("id_salt", &self.id_salt)
.field(
"heading",
&self.heading.as_ref().map(|h| h.text()),
)
.field(
"subtitle",
&self.subtitle.as_ref().map(|h| h.text()),
)
.field(
"header_icon",
&self.header_icon.as_ref().map(|h| h.text()),
)
.field("backdrop_order", &self.backdrop_order)
.field("content_order", &self.content_order)
.field("open", &*self.open)
.field("max_width", &self.max_width)
.field("closable", &self.closable)
.field("close_on_backdrop", &self.close_on_backdrop)
.field("close_on_escape", &self.close_on_escape)
.field("alert", &self.alert)
.field(
"footer",
&self.footer.as_ref().map(|_| "<closure>"),
)
.field(
"footer_left",
&self.footer_left.as_ref().map(|_| "<closure>"),
)
.finish()
}
}
impl<'a> Modal<'a> {
pub fn new(id: impl Into<Id>, open: &'a mut bool) -> Self {
Self {
id_salt: Id::new(id.into()),
heading: None,
subtitle: None,
header_icon: None,
backdrop_order: Order::Middle,
content_order: Order::Foreground,
open,
max_width: 440.0,
closable: true,
close_on_backdrop: true,
close_on_escape: true,
alert: false,
footer: None,
footer_left: None,
}
}
pub fn heading(mut self, heading: impl Into<WidgetText>) -> Self {
self.heading = Some(heading.into());
self
}
pub fn subtitle(mut self, subtitle: impl Into<WidgetText>) -> Self {
self.subtitle = Some(subtitle.into());
self
}
pub fn header_icon(mut self, icon: impl Into<WidgetText>) -> Self {
self.header_icon = Some(icon.into());
self
}
pub fn backdrop_order(mut self, order: Order) -> Self {
self.backdrop_order = order;
self
}
pub fn content_order(mut self, order: Order) -> Self {
self.content_order = order;
self
}
pub fn max_width(mut self, max_width: f32) -> Self {
self.max_width = max_width;
self
}
pub fn closable(mut self, closable: bool) -> Self {
self.closable = closable;
self
}
pub fn close_on_backdrop(mut self, close: bool) -> Self {
self.close_on_backdrop = close;
self
}
pub fn close_on_escape(mut self, close: bool) -> Self {
self.close_on_escape = close;
self
}
pub fn alert(mut self, alert: bool) -> Self {
self.alert = alert;
self
}
pub fn footer<F: FnOnce(&mut Ui) + 'a>(mut self, add_footer: F) -> Self {
self.footer = Some(Box::new(add_footer));
self
}
pub fn footer_left<F: FnOnce(&mut Ui) + 'a>(mut self, add_left: F) -> Self {
self.footer_left = Some(Box::new(add_left));
self
}
pub fn show<R>(self, ctx: &Context, add_contents: impl FnOnce(&mut Ui) -> R) -> Option<R> {
let focus_storage = Id::new(("elegance_modal_focus", self.id_salt));
let mut focus_state: ModalFocusState =
ctx.data(|d| d.get_temp(focus_storage).unwrap_or_default());
let is_open = *self.open;
if focus_state.was_open && !is_open {
if let Some(prev) = focus_state.prev_focus {
ctx.memory_mut(|m| m.request_focus(prev));
}
ctx.data_mut(|d| d.insert_temp(focus_storage, ModalFocusState::default()));
return None;
}
if !is_open {
return None;
}
let just_opened = !focus_state.was_open;
if just_opened {
focus_state.prev_focus = ctx.memory(|m| m.focused());
focus_state.was_open = true;
ctx.data_mut(|d| d.insert_temp(focus_storage, focus_state));
}
let theme = Theme::current(ctx);
let mut should_close = false;
let mut close_btn_id: Option<Id> = None;
let closable = self.closable;
let screen = ctx.content_rect();
let backdrop_id = Id::new("elegance_modal_backdrop").with(self.id_salt);
let backdrop =
Area::new(backdrop_id)
.fixed_pos(screen.min)
.order(self.backdrop_order)
.show(ctx, |ui| {
ui.painter().rect_filled(
screen,
CornerRadius::ZERO,
Color32::from_rgba_premultiplied(0, 0, 0, 150),
);
ui.allocate_rect(screen, Sense::click())
});
if closable && self.close_on_backdrop && backdrop.inner.clicked() {
should_close = true;
}
let window_id = Id::new("elegance_modal_window").with(self.id_salt);
let alert = self.alert;
let heading_text: Option<String> = self.heading.as_ref().map(|h| h.text().to_string());
let result = Area::new(window_id)
.order(self.content_order)
.anchor(Align2::CENTER_CENTER, Vec2::ZERO)
.show(ctx, |ui| {
let role = if alert {
accesskit::Role::AlertDialog
} else {
accesskit::Role::Dialog
};
let heading_for_label = heading_text.clone();
ui.ctx().accesskit_node_builder(ui.unique_id(), |node| {
node.set_role(role);
if let Some(label) = heading_for_label {
node.set_label(label);
}
});
ui.set_max_width(self.max_width);
Frame::new()
.fill(theme.colors.widget_bg)
.stroke(Stroke::new(1.0, theme.colors.border))
.corner_radius(theme.frame1.corner_radius)
.show(ui, |ui| {
let pad = theme.frame1.inner_margin.left;
let has_heading = self.heading.is_some();
let has_icon = self.header_icon.is_some();
if has_heading || has_icon {
Frame::new()
.inner_margin(Margin {
left: pad as i8,
right: pad as i8,
top: pad as i8,
bottom: 0,
})
.show(ui, |ui| {
ui.horizontal_top(|ui| {
if let Some(icon) = &self.header_icon {
paint_icon_halo(ui, icon.text(), &theme);
ui.add_space(10.0);
}
ui.vertical(|ui| {
if let Some(h) = &self.heading {
ui.add(egui::Label::new(h.clone()));
}
if let Some(sub) = &self.subtitle {
ui.add(egui::Label::new(sub.clone()));
}
});
ui.with_layout(Layout::right_to_left(Align::Min), |ui| {
if closable {
let resp = close_button(ui, &theme);
if resp.clicked() {
should_close = true;
}
close_btn_id = Some(resp.id);
}
});
});
});
ui.add_space(6.0);
ui.separator();
ui.add_space(10.0);
}
let body_result = Frame::new()
.inner_margin(Margin {
left: pad as i8,
right: pad as i8,
top: if has_heading || has_icon {
0
} else {
pad as i8
},
bottom: if self.footer.is_some() {
pad as i8 / 2
} else {
pad as i8
},
})
.show(ui, |ui| add_contents(ui))
.inner;
if let Some(footer) = self.footer {
ui.separator();
let footer_fill = theme.colors.widget_bg;
let fill_idx = ui.painter().add(Shape::Noop);
let footer_rect = Frame::new()
.inner_margin(Margin::symmetric(pad as i8, pad as i8 * 3 / 4))
.show(ui, |ui| {
ui.horizontal(|ui| {
if let Some(left) = self.footer_left {
left(ui);
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
footer(ui);
});
});
})
.response
.rect;
let card_radius = theme.frame1.corner_radius.ne as f32;
let r = (card_radius - 1.0).max(0.0) as u8;
let fill_rect = Rect::from_min_max(
Pos2::new(footer_rect.left() + 1.0, footer_rect.top()),
Pos2::new(
footer_rect.right() - 1.0,
footer_rect.bottom() - 1.0,
),
);
ui.painter().set(
fill_idx,
Shape::rect_filled(
fill_rect,
CornerRadius {
nw: 0,
ne: 0,
sw: r,
se: r,
},
footer_fill,
),
);
}
body_result
})
});
if closable && self.close_on_escape && ctx.input(|i| i.key_pressed(Key::Escape)) {
should_close = true;
}
if just_opened && let Some(id) = close_btn_id {
ctx.memory_mut(|m| m.request_focus(id));
}
if should_close {
*self.open = false;
if let Some(prev) = focus_state.prev_focus {
ctx.memory_mut(|m| m.request_focus(prev));
}
ctx.data_mut(|d| d.insert_temp(focus_storage, ModalFocusState::default()));
}
Some(result.inner.inner)
}
}
#[derive(Clone, Copy, Default, Debug)]
struct ModalFocusState {
was_open: bool,
prev_focus: Option<Id>,
}
fn close_button(ui: &mut Ui, theme: &Theme) -> Response {
let inner = ui
.push_id("modal_close", |ui| {
let text = RichText::new("X").size(theme.text_sizes.normal);
ui.add(Button::new(text).min_size(vec2(20.0, 20.0)))
})
.inner;
let enabled = inner.enabled();
inner.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, "Close"));
inner
}
fn paint_icon_halo(ui: &mut Ui, glyph: &str, theme: &Theme) {
let size = 32.0;
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
let fg = theme.colors.accent;
let bg = Color32::from_rgba_unmultiplied(fg.r(), fg.g(), fg.b(), 36);
let painter = ui.painter();
painter.circle_filled(rect.center(), size * 0.5, bg);
painter.text(
rect.center(),
Align2::CENTER_CENTER,
glyph,
FontId::proportional(theme.text_sizes.heading + 2.0),
fg,
);
}