facett-app/src/modal.rs
//! **`Modal` — a reusable dialog overlay.** Generalises the `facett-menu`
//! `CommandPalette` pattern (a scrim + a centred panel at `Order::Foreground`,
//! Esc to dismiss) into an addressable dialog: a title + body + buttons. All state
//! (incl. `open` and the last action) lives in the struct (FC-1), so it is
//! headless-drivable (`open`/`close`/`take_action`) and observable (`state_json`).
use serde_json::{Value, json};
/// A modal dialog. State lives here, never in egui memory.
pub struct Modal {
id_salt: String,
title: String,
body: String,
/// `(id, label)` buttons, left→right.
buttons: Vec<(String, String)>,
open: bool,
/// The id of the last button pressed (or "dismiss" on scrim/Esc), unconsumed.
last_action: Option<String>,
/// Whether clicking the scrim / pressing Esc dismisses (default true).
dismissable: bool,
}
impl Modal {
pub fn new(id_salt: impl Into<String>, title: impl Into<String>) -> Self {
Self { id_salt: id_salt.into(), title: title.into(), body: String::new(), buttons: Vec::new(), open: false, last_action: None, dismissable: true }
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = body.into();
self
}
pub fn button(mut self, id: impl Into<String>, label: impl Into<String>) -> Self {
self.buttons.push((id.into(), label.into()));
self
}
/// Preset: an OK / Cancel confirm dialog (`"ok"` / `"cancel"`).
pub fn confirm_cancel(self) -> Self {
self.button("cancel", "Cancel").button("ok", "OK")
}
pub fn dismissable(mut self, on: bool) -> Self {
self.dismissable = on;
self
}
pub fn open(&mut self) {
self.open = true;
}
pub fn close(&mut self) {
self.open = false;
}
pub fn is_open(&self) -> bool {
self.open
}
/// Consume + return the last action id (button id, or "dismiss").
pub fn take_action(&mut self) -> Option<String> {
self.last_action.take()
}
pub fn action(&self) -> Option<&str> {
self.last_action.as_deref()
}
/// Draw the modal if open. Returns the chosen action id this frame (button id
/// or "dismiss"); also stored for `take_action`.
pub fn show(&mut self, ui: &mut egui::Ui) -> Option<String> {
if !self.open {
return None;
}
let ctx = ui.ctx().clone();
let screen = ctx.content_rect();
let mut chosen: Option<String> = None;
// Scrim — dim + swallow clicks behind the dialog.
egui::Area::new(egui::Id::new((&self.id_salt, "modal_scrim")))
.order(egui::Order::Background)
.fixed_pos(screen.min)
.show(&ctx, |ui| {
let resp = ui.allocate_rect(screen, egui::Sense::click());
ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160));
// FC-4: the clickable backdrop is a real, labelled a11y node.
if self.dismissable {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "close dialog (backdrop)"));
if resp.clicked() {
chosen = Some("dismiss".into());
}
}
});
// Dialog panel, centred + on top.
let buttons = self.buttons.clone();
let title = self.title.clone();
let body = self.body.clone();
egui::Area::new(egui::Id::new((&self.id_salt, "modal_panel")))
.order(egui::Order::Foreground)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.show(&ctx, |ui| {
egui::Frame::popup(ui.style()).inner_margin(16.0).show(ui, |ui| {
ui.set_max_width(420.0);
ui.heading(&title);
if !body.is_empty() {
ui.add_space(6.0);
ui.label(&body);
}
ui.add_space(12.0);
ui.separator();
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
for (id, label) in buttons.iter().rev() {
if ui.button(label).clicked() {
chosen = Some(id.clone());
}
}
});
});
});
});
if self.dismissable && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
chosen = Some("dismiss".into());
}
if let Some(action) = &chosen {
self.last_action = Some(action.clone());
self.open = false;
}
chosen
}
pub fn state_json(&self) -> Value {
json!({
"title": self.title,
"open": self.open,
"buttons": self.buttons.iter().map(|(i, _)| i).collect::<Vec<_>>(),
"last_action": self.last_action,
})
}
}