Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

features/modal.rs

//! `modal` (`docs/adr/0003-modal-surfaces.md`): a dialog over a form, and
//! a confirm over the dialog — the two things a page of its own can show
//! that a menu cannot.
//!
//! - **Entry.** The dialog opens with focus on its *Save* button, because
//!   that button declares `initial_focus`; without it focus would land on
//!   the first stop, the field. The ring is the dialog: Tab wraps inside
//!   it and never reaches the form.
//! - **Nesting.** *Discard* opens a confirm *inside* the dialog. The last
//!   `modal` declared wins, so the dialog goes as inert as the form under
//!   it — a stack without a stack (decision 2).
//! - **Escape is the app's call.** On the confirm it closes the confirm;
//!   on the dialog it closes the dialog when the field is untouched and
//!   asks the confirm otherwise (decision 6: the core asks, the app
//!   decides). A press outside — the pointer's, or a reader's click on
//!   a node behind the modal — is the same `dismiss` with `reason:
//!   "outside"`, answered the same way.
//! - **Restore.** When the dialog goes, focus returns to the button that
//!   opened it (decision 4), so a keyboard user is where they were.
//!
//! Run: cargo run -p kui-native --example modal [-- --headless]

use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{Align, App, Core, FloatConfig, NodeSpec, TextStyle, Ui, UiEvent, Value};

#[derive(Default)]
struct Page {
    dialog: bool,
    confirm: bool,
    /// What the last dialog saved, shown on the form.
    saved: Option<String>,
    /// The dialog field's text, read off the editor every frame (a
    /// `changed` event says *that* it changed; the text is the core's to
    /// read), which is what "untouched" is judged by.
    draft: String,
}

impl App for Page {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        ui.with(
            NodeSpec::column().fill().pad(24.0).gap(14.0).bg(t.bg),
            |ui| {
                ui.text("modal", TextStyle::new(22.0).color(t.fg));
                ui.text(
                    "the form is the main ring; a dialog confines it, a confirm over the dialog confines it again",
                    TextStyle::new(12.0).color(t.muted),
                );
                ui.with(NodeSpec::row().gap(10.0), |ui| {
                    widgets::button(ui, "open dialog", Value::str("open"));
                    widgets::button(ui, "another button", Value::str("noop"));
                });
                ui.with(NodeSpec::column().width(260.0), |ui| {
                    widgets::text_input(ui, "form field", "");
                });
                if let Some(s) = &self.saved {
                    ui.text(&format!("saved: {s:?}"), TextStyle::new(12.0).color(t.muted));
                }
            },
        );
        if self.dialog {
            let mut draft = None;
            ui.with_keyed(
                "dialog",
                NodeSpec::column()
                    .float(FloatConfig::viewport().inside(Align::Center, Align::Center))
                    .modal("dialog")
                    .role(kui_native::Role::Dialog)
                    .label("Edit")
                    .width(320.0)
                    .pad(18.0)
                    .gap(12.0)
                    .bg(t.raised)
                    .border(1.0, t.border_strong)
                    .radius(10.0),
                |ui| {
                    ui.text("edit", TextStyle::new(16.0).color(t.fg));
                    let name = widgets::text_input(ui, "name", "");
                    draft = ui.edit_text(name);
                    ui.with(
                        NodeSpec::row().grow_width().gap(8.0).main_align(Align::End),
                        |ui| {
                            widgets::button(ui, "discard", Value::str("discard"));
                            // `initial_focus`: the dialog opens here, not on the
                            // field — the entry precedence of ADR 0003 / 0007.
                            let spec = widgets::button_spec(&ui.theme(), &ui.metrics())
                                .accent()
                                .on_click("save")
                                .initial_focus();
                            widgets::button_with(ui, "save", "save", spec, None);
                        },
                    );
                    // The confirm, declared inside the dialog: the later
                    // `modal` is the one in effect.
                    if self.confirm {
                        ui.with_keyed(
                            "confirm",
                            NodeSpec::column()
                                .float(FloatConfig::viewport().inside(Align::Center, Align::Center))
                                .modal("confirm")
                                .role(kui_native::Role::Dialog)
                                .label("Discard changes?")
                                .width(240.0)
                                .pad(16.0)
                                .gap(10.0)
                                .bg(t.raised)
                                .border(1.0, t.danger)
                                .radius(8.0),
                            |ui| {
                                ui.text("discard changes?", TextStyle::new(14.0).color(t.fg));
                                ui.with(
                                    NodeSpec::row().grow_width().gap(8.0).main_align(Align::End),
                                    |ui| {
                                        widgets::button(ui, "keep editing", Value::str("keep"));
                                        widgets::button(ui, "discard them", Value::str("really"));
                                    },
                                );
                            },
                        );
                    }
                },
            );
            if let Some(text) = draft {
                self.draft = text;
            }
        }
    }

    fn on_event(&mut self, ev: UiEvent) {
        let kind = ev.kind();
        if kind == Some("dismiss") {
            // Which modal asked: the tag is the node's.
            match ev.payload.get_str("tag") {
                Some("confirm") => self.confirm = false,
                Some("dialog") if self.draft.is_empty() => self.dialog = false,
                Some("dialog") => self.confirm = true,
                _ => {}
            }
            return;
        }
        match ev.payload.as_str() {
            Some("open") => {
                self.dialog = true;
                self.confirm = false;
                self.draft.clear();
            }
            Some("save") => {
                self.saved = Some(self.draft.clone());
                self.dialog = false;
            }
            Some("discard") => self.confirm = true,
            Some("keep") => self.confirm = false,
            Some("really") => {
                self.confirm = false;
                self.dialog = false;
            }
            _ => {}
        }
    }
}

impl Example for Page {
    /// Entry on `initial_focus`, the ring confined, the confirm over the
    /// dialog making it inert, Escape routed by tag, and focus restored to
    /// the opener.
    fn headless(&mut self, core: &mut Core) -> Result<(), String> {
        let mut d = Drive::new(core, 700.0, 500.0);
        d.frame(self);
        let open = d.key_of("open dialog").unwrap();
        let other = d.key_of("another button").unwrap();
        d.focus(self, open);
        d.frame(self);
        d.click_key(self, open);
        d.frame(self);
        let save = d.key_of("save").unwrap();
        let discard = d.key_of("discard").unwrap();
        let name = d.key_of("name").unwrap();
        d.check(
            d.core.focus() == Some(save),
            "the dialog opens on save (initial_focus)",
        )?;
        // Tab wraps inside the dialog: name → discard → save → name.
        d.key(self, "tab", Default::default());
        d.check(d.core.focus() == Some(name), "Tab wraps to the field")?;
        d.key(self, "tab", Default::default());
        d.check(d.core.focus() == Some(discard), "then discard")?;
        d.key(self, "tab", Default::default());
        d.check(
            d.core.focus() == Some(save),
            "then save again, never the form",
        )?;
        // The form is inert: a reader's click on its other button is the
        // press outside (decision 6, backlog RG13) — one `dismiss` on the
        // dialog with `reason: "outside"`, nothing on the button — which
        // this app answers as it answers Escape: an untouched dialog
        // closes, and focus is back on the opener.
        let evs = d.click_key(self, other);
        let outside = evs.len() == 1
            && evs[0].key != other
            && evs[0].kind() == Some("dismiss")
            && evs[0].payload.get_str("tag") == Some("dialog")
            && evs[0].payload.get_str("reason") == Some("outside");
        d.check(
            outside,
            "the form under the dialog is inert: the click is the press outside",
        )?;
        d.frame(self);
        d.check(
            !self.dialog,
            "an untouched dialog closes on the press outside",
        )?;
        d.check(
            d.core.focus() == Some(open),
            "and focus is back on the opener",
        )?;
        d.click_key(self, open);
        d.frame(self);
        // Escape on an untouched dialog closes it, and focus comes back
        // to the button that opened it.
        d.key(self, "escape", Default::default());
        d.frame(self);
        d.check(!self.dialog, "Escape closes an untouched dialog")?;
        d.check(d.core.focus() == Some(open), "focus is back on the opener")?;
        // Open again, type, then Escape asks the confirm instead.
        d.click_key(self, open);
        d.frame(self);
        let name = d.key_of("name").unwrap();
        d.focus(self, name);
        d.text(self, "hi");
        d.frame(self);
        d.key(self, "escape", Default::default());
        d.frame(self);
        d.check(
            self.dialog && self.confirm,
            "Escape on a dirty dialog asks the confirm",
        )?;
        let keep = d.key_of("keep editing").unwrap();
        let really = d.key_of("discard them").unwrap();
        d.check(
            d.core.focus() == Some(keep),
            "the confirm opens on its first stop, and holds focus",
        )?;
        // The dialog is now as inert as the form: a reader's click on its
        // save button is the press outside the *confirm* — the modal in
        // effect — and nothing on save; this app closes the confirm on
        // any dismiss, so the dialog stands, dirty, and the field has
        // focus again. Escape then asks the confirm once more.
        let save = d.key_of("save").unwrap();
        let evs = d.click_key(self, save);
        let outside = evs.len() == 1
            && evs[0].key != save
            && evs[0].kind() == Some("dismiss")
            && evs[0].payload.get_str("tag") == Some("confirm");
        d.check(
            outside,
            "the dialog under the confirm is inert: the click asks the confirm",
        )?;
        d.frame(self);
        d.check(
            self.dialog && !self.confirm,
            "the press outside closes the confirm, and the dialog stands",
        )?;
        d.check(
            d.core.focus() == Some(name),
            "focus returns to the field the confirm displaced",
        )?;
        d.key(self, "escape", Default::default());
        d.frame(self);
        d.check(self.confirm, "Escape asks the confirm again")?;
        d.key(self, "escape", Default::default());
        d.frame(self);
        d.check(
            self.dialog && !self.confirm,
            "Escape on the confirm closes only it",
        )?;
        d.check(
            d.core.focus() == Some(name),
            "and focus returns to the field the confirm displaced",
        )?;
        // Discard for real: both go, focus back on the opener.
        d.click_key(self, discard);
        d.frame(self);
        d.click_key(self, really);
        d.frame(self);
        d.check(!self.dialog && !self.confirm, "discarding closes both")?;
        d.check(
            d.core.focus() == Some(open),
            "focus is back on the opener again",
        )?;
        Ok(())
    }
}

kui_devtools::main!(Page::default());