Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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,
        })
    }
}