//! 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, } 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) -> 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) { 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 { 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) -> 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, } 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 = self .dialog_ids() .into_iter() .filter(|id| self.widget::(*id).is_some_and(Dialog::is_open)) .collect(); // Shut what the app closed. let stale: Vec = 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) .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) { 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;