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;