dialog.rs raw

//! Dialogs: a modal sheet the app opens by binding `:open`.
//!
//! Unlike a menu, a dialog has nothing to click — it is opened by the
//! program, not by the pointer — so it cannot report and let the tree act.
//! It is driven by state instead: `:open` is a **controlled** bool — the app
//! owns it, and the dialog never flips it.
//!
//! That is why Escape *reports* rather than dismisses. If the overlay closed
//! itself, `:open` would still say true and the app's state would have
//! quietly diverged from the screen. So `dismiss_on_escape` is off here: a
//! dialog wants the report, and the app answers by clearing `:open`.
//! The binding stays the single source of truth, which is the whole point of
//! a controlled value.
//!
//! Everything else is the overlay layer's: `OverlayOptions::dialog()` centers,
//! traps focus, and is modal; the focus invariant puts focus on the first
//! control inside; closing restores what had it before.
//!
//! # A known flaw: these are not real dialog windows
//!
//! A dialog *should* be a window. A real one is managed by the compositor,
//! not by us: it can be moved outside its parent, gets decorations and
//! placement for free, follows the parent across workspaces, is floated
//! rather than tiled, and is announced to assistive technology as a window
//! rather than as a group of widgets that happens to be on top. An in-window
//! sheet is an imitation of all of that, and it is worse at every one.
//!
//! We draw a sheet because we cannot do better *yet*, not because it is
//! right. On Wayland a toplevel is a dialog by virtue of
//! `xdg_toplevel.set_parent` — there is no window-type property the way X11
//! has `_NET_WM_WINDOW_TYPE_DIALOG`; the parent link *is* the metadata, and
//! it is what a compositor keys on to float and stack it. winit 0.30 cannot
//! make that call: `with_parent_window` documents Wayland as unsupported, its
//! Wayland backend never reads the field, and `raw-window-handle` hands out
//! only the `wl_surface` — the `xdg_toplevel` never leaves winit, and a
//! surface cannot be given a second role. So the object we would need to name
//! is unreachable from here by construction, not by omission.
//!
//! **This should be fixed when winit supports it more broadly** — most
//! likely by contributing `set_parent` to its Wayland backend, which its own
//! API already promises. When that lands, real dialog windows are the goal
//! and this sheet becomes the fallback for platforms that cannot manage one,
//! rather than the only thing we know how to do. The declarative surface
//! below (`:open` controlled, `:title`, deferred body, `:on-close`) is chosen
//! to survive that change: nothing in it says "overlay".

use guiduck_scene::Fragment;
use guiduck_scene::geom::{Rect, Size};
use guiduck_scene::paint::{Brush, Color};

use super::{Widget, WidgetId, WidgetTree};
use crate::content::ContentBuilder;
use crate::dirty::Dirt;
use crate::event::EventData;
use crate::style::{ComputedStyle, StyleReader};

/// Padding inside the sheet.
const SHEET_PAD: f64 = 16.0;

/// The appearance tokens a dialog sheet reads from the theme.
struct Tokens {
    background: Brush,
    color: Brush,
    border_color: Brush,
    border_width: f64,
    corner_radius: f64,
    scrim_color: Brush,
}

impl Tokens {
    fn read(&mut self, style: &ComputedStyle) -> bool {
        let mut reader = StyleReader::new(style);
        reader.brush(&mut self.background, "background");
        reader.brush(&mut self.color, "color");
        reader.brush(&mut self.border_color, "border-color");
        reader.number(&mut self.border_width, "border-width");
        reader.number(&mut self.corner_radius, "corner-radius");
        reader.brush(&mut self.scrim_color, "scrim-color");
        reader.changed()
    }
}

impl Default for Tokens {
    fn default() -> Self {
        let mut tokens = Self {
            background: Color::TRANSPARENT.into(),
            color: Color::TRANSPARENT.into(),
            border_color: Color::TRANSPARENT.into(),
            border_width: 0.0,
            corner_radius: 0.0,
            scrim_color: Color::TRANSPARENT.into(),
        };
        tokens.read(&super::fallback_style("dialog"));
        tokens
    }
}

/// The declaration site of a dialog: it draws nothing and takes no space.
///
/// Like a `context-menu`, it is an *attachment point* that happens to live in
/// the tree, so it can be written where the thing it belongs to is written
/// and close over the same scope. The sheet the tree builds is a
/// [`DialogSheet`].
pub struct Dialog {
    open: bool,
    title: String,
    content: ContentBuilder,
    dirt: Dirt,
    emitted: Vec<EventData>,
}

impl Dialog {
    pub fn new() -> Self {
        Self {
            open: false,
            title: String::new(),
            content: ContentBuilder::default(),
            dirt: Dirt::CLEAN,
            emitted: Vec::new(),
        }
    }

    pub fn content(mut self, content: ContentBuilder) -> Self {
        self.set_content(content);
        self
    }

    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.set_title(title);
        self
    }

    pub fn open(mut self, open: bool) -> Self {
        self.set_open(open);
        self
    }

    pub fn is_open(&self) -> bool {
        self.open
    }

    pub fn title_text(&self) -> &str {
        &self.title
    }

    pub fn content_builder(&self) -> ContentBuilder {
        self.content.clone()
    }

    /// Bound to `:open`. Equality-gated and silent — the controlled contract,
    /// so a binding cannot loop. The tree notices the change and opens or
    /// closes the sheet.
    pub fn set_open(&mut self, open: bool) {
        if self.open != open {
            self.open = open;
            // Not paint: this widget paints nothing. It is a request for the
            // tree to reconcile the sheet, which it does on the next frame.
            self.dirt.mark_paint();
        }
    }

    pub fn set_title(&mut self, title: impl Into<String>) {
        let title = title.into();
        if self.title != title {
            self.title = title;
            self.dirt.mark_paint();
        }
    }

    /// The user asked to dismiss. A *request*: the app answers it by
    /// clearing `:open`, so the binding stays the truth.
    pub fn request_close(&mut self) {
        self.emitted.push(EventData::CloseRequested);
    }
}

impl Default for Dialog {
    fn default() -> Self {
        Self::new()
    }
}

impl Widget for Dialog {
    fn defers_content(&self) -> bool {
        true
    }

    /// The sheet's body, built each time it opens.
    fn set_content(&mut self, content: ContentBuilder) {
        self.content = content;
    }

    fn adjust_style(&self, style: &mut taffy::Style) {
        style.display = taffy::Display::None;
    }

    fn take_dirt(&mut self) -> Dirt {
        std::mem::take(&mut self.dirt)
    }

    fn take_emitted(&mut self) -> Vec<EventData> {
        std::mem::take(&mut self.emitted)
    }

    fn type_name(&self) -> &'static str {
        "dialog"
    }
}

/// The dimmed backdrop behind a sheet, filling the viewport.
///
/// It is a widget rather than a paint call on the overlay root because the
/// scrim *is* the layer: it fills the window and centers the sheet inside
/// itself, which is also what makes the sheet's own size its content's.
pub struct DialogScrim {
    tokens: Tokens,
    dirt: Dirt,
}

impl DialogScrim {
    pub fn new() -> Self {
        Self {
            tokens: Tokens::default(),
            dirt: Dirt::CLEAN,
        }
    }
}

impl Default for DialogScrim {
    fn default() -> Self {
        Self::new()
    }
}

impl Widget for DialogScrim {
    fn adjust_style(&self, style: &mut taffy::Style) {
        style.size = taffy::Size {
            width: taffy::prelude::percent(1.0_f32),
            height: taffy::prelude::percent(1.0_f32),
        };
        style.display = taffy::Display::Flex;
        style.align_items = Some(taffy::AlignItems::CENTER);
        style.justify_content = Some(taffy::JustifyContent::CENTER);
    }

    fn paint(&mut self, fragment: &mut Fragment, size: Size) {
        fragment.fill(
            Rect::from_origin_size((0.0, 0.0), size),
            self.tokens.scrim_color.clone(),
        );
    }

    fn take_dirt(&mut self) -> Dirt {
        std::mem::take(&mut self.dirt)
    }

    fn type_name(&self) -> &'static str {
        "dialog"
    }

    fn apply_style(&mut self, style: &ComputedStyle) {
        if self.tokens.read(style) {
            self.dirt.mark_paint();
        }
    }
}

/// The panel a dialog puts on screen. It paints chrome only: the title is a
/// `Text` child, so flex gives it room — painting it here reserved no space
/// and ran straight over the body.
pub struct DialogSheet {
    /// The title, for the accessible name only — a `Text` child draws it.
    /// A dialog announces itself by name, and assistive technology reads the
    /// node with the role, not whatever happens to be inside it.
    title: String,
    tokens: Tokens,
    dirt: Dirt,
}

impl DialogSheet {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            tokens: Tokens::default(),
            dirt: Dirt::CLEAN,
        }
    }
}

impl Widget for DialogSheet {
    fn adjust_style(&self, style: &mut taffy::Style) {
        style.display = taffy::Display::Flex;
        style.flex_direction = taffy::FlexDirection::Column;
        style.padding = taffy::Rect::length(SHEET_PAD as f32);
        style.gap = taffy::Size {
            width: taffy::prelude::length(0.0_f32),
            height: taffy::prelude::length(10.0_f32),
        };
    }

    fn paint(&mut self, fragment: &mut Fragment, size: Size) {
        let t = &self.tokens;
        let bounds = Rect::from_origin_size((0.0, 0.0), size);
        fragment.fill(
            bounds.to_rounded_rect(t.corner_radius),
            t.background.clone(),
        );
        if t.border_width > 0.0 {
            fragment.stroke(
                bounds
                    .inset(-t.border_width / 2.0)
                    .to_rounded_rect((t.corner_radius - t.border_width / 2.0).max(0.0)),
                t.border_color.clone(),
                guiduck_scene::geom::Stroke::new(t.border_width),
            );
        }
    }

    fn role(&self) -> accesskit::Role {
        accesskit::Role::Dialog
    }

    fn accessibility(&self, node: &mut accesskit::Node) {
        if !self.title.is_empty() {
            node.set_label(self.title.clone());
        }
        node.set_modal();
    }

    fn take_dirt(&mut self) -> Dirt {
        std::mem::take(&mut self.dirt)
    }

    fn type_name(&self) -> &'static str {
        "dialog"
    }

    fn apply_style(&mut self, style: &ComputedStyle) {
        if self.tokens.read(style) {
            self.dirt.mark_paint();
        }
    }
}

/// One open dialog: what put it there, and what it put on screen.
pub(crate) struct OpenDialog {
    pub(crate) dialog: WidgetId,
    pub(crate) overlay: WidgetId,
    pub(crate) scope: Option<crate::signals::Scope>,
}

impl WidgetTree {
    /// Reconcile every dialog's sheet against its `:open` flag.
    ///
    /// Driven from the frame, not from a setter: `:open` is a binding, and a
    /// binding's effect lands in the command queue, so the truth is whatever
    /// the flag says once the frame's mutations have settled.
    pub(crate) fn sync_dialogs(&mut self) {
        let wanted: Vec<WidgetId> = self
            .dialog_ids()
            .into_iter()
            .filter(|id| self.widget::<Dialog>(*id).is_some_and(Dialog::is_open))
            .collect();

        // Shut what the app closed.
        let stale: Vec<WidgetId> = self
            .dialogs
            .iter()
            .filter(|open| !wanted.contains(&open.dialog))
            .map(|open| open.dialog)
            .collect();
        for dialog in stale {
            self.close_dialog(dialog);
        }
        // Open what it opened.
        for dialog in wanted {
            if !self.dialogs.iter().any(|open| open.dialog == dialog) {
                self.open_dialog(dialog);
            }
        }
    }

    fn open_dialog(&mut self, dialog: WidgetId) {
        let Some((title, content)) = self
            .widget::<Dialog>(dialog)
            .map(|w| (w.title_text().to_owned(), w.content_builder()))
        else {
            return;
        };
        let commands = self.commands();
        let mut options = super::overlay::OverlayOptions::dialog();
        // Escape reports; it does not dismiss. Closing here would leave
        // `:open` saying true while the sheet was gone.
        options.dismiss_on_escape = false;
        options.on_close = Some(Box::new(move || {
            commands.push(move |tree| tree.dialog_closed(dialog));
        }));
        // The layer is the whole window: the scrim dims all of it and centres
        // the sheet inside itself. An auto-sized root would leave the scrim's
        // `100%` resolving against its own content, which is circular — and
        // silently yields no scrim at all.
        let overlay = self.open_overlay(
            taffy::Style {
                size: taffy::Size {
                    width: taffy::prelude::percent(1.0_f32),
                    height: taffy::prelude::percent(1.0_f32),
                },
                ..Default::default()
            },
            options,
        );
        let scrim = self.insert(DialogScrim::new(), taffy::Style::default(), Some(overlay));
        let sheet = self.insert(
            DialogSheet::new(title.clone()),
            taffy::Style::default(),
            Some(scrim),
        );
        if !title.is_empty() {
            self.insert(
                super::Text::new(title).font_size(16.0),
                taffy::Style::default(),
                Some(sheet),
            );
        }
        let scope = crate::signals::Scope::new();
        scope.run(|| content.build(self, sheet));
        self.dialogs.push(OpenDialog {
            dialog,
            overlay,
            scope: Some(scope),
        });
    }

    fn close_dialog(&mut self, dialog: WidgetId) {
        let Some(index) = self.dialogs.iter().position(|open| open.dialog == dialog) else {
            return;
        };
        let open = self.dialogs.remove(index);
        if let Some(scope) = open.scope {
            scope.dispose();
        }
        if self.is_overlay(open.overlay) {
            self.close_overlay(open.overlay);
        }
    }

    /// The overlay went on its own (the tree was torn down around it).
    pub(crate) fn dialog_closed(&mut self, dialog: WidgetId) {
        if let Some(index) = self.dialogs.iter().position(|open| open.dialog == dialog) {
            let open = self.dialogs.remove(index);
            if let Some(scope) = open.scope {
                scope.dispose();
            }
        }
    }

    /// Escape landed on a dialog's sheet: report it to the dialog that owns
    /// the topmost one. Returns whether there was one.
    pub(crate) fn request_close_topmost_dialog(&mut self) -> bool {
        let Some(open) = self.dialogs.last() else {
            return false;
        };
        let dialog = open.dialog;
        if let Some(mut widget) = self.widget_mut::<Dialog>(dialog) {
            widget.request_close();
        }
        // A `WidgetMut` collects dirt but not emitted events, so the report
        // is dispatched here rather than left to be picked up.
        self.dispatch_semantic(
            dialog,
            crate::event::EventKind::Close,
            EventData::CloseRequested,
        );
        true
    }
}

#[cfg(test)]
mod tests;