Skip to main content

teksilo_widgets/
dialog.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Modal dialogs — a trigger button that presents a centered modal panel.
5//!
6//! Three cooperating types cover the common dialog use-case. [`Dialog`] is the
7//! high-level entry point: a `Button` (or custom trigger) that, on activation,
8//! presents a `ModalContainer` above a full-viewport dimming [`ModalScrim`].
9//! [`DialogContent`] is the convenience body layout — a `VStack` with an
10//! optional title, supporting text, scrollable body slot, and a footer slot
11//! separated by a `Divider`.
12//!
13//! ## When to use
14//!
15//! - `Dialog::new(label).content(|| …)` for the common "button opens dialog" pattern.
16//! - `Dialog::new(label).trigger(my_icon_button).content(|| …)` to use a custom widget
17//!   as the trigger instead of the default `Button`.
18//! - `ModalContainer::new(content)` directly when you need to present a modal from
19//!   handler code via `ctx.present_modal(ModalRequest::…)` rather than a persistent
20//!   trigger.
21//!
22//! ## Accessibility
23//!
24//! `ModalContainer` is a `Role::Dialog` node and announces `set_modal()`.
25//! Its accessible name defaults to the `DialogContent` title (via
26//! `Widget::accessible_title_hint`) or falls back to the localized
27//! `a11y_dialog_name` message; pass `.title(tr!(…))` to the container for an
28//! explicit override. When the content's own node is already a dialog (a
29//! `MessageBox`'s `Role::AlertDialog`, a `CommandPalette`'s `Role::Dialog`),
30//! the container publishes no node of its own, so a reader meets one dialog
31//! and hears its title once. The trigger button advertises `HasPopup::Dialog` and
32//! `set_expanded` tracks whether the modal is currently open.
33//!
34//! ```ignore
35//! use teksilo_widgets::dialog::{Dialog, DialogContent};
36//! use teksilo_i18n::lit;
37//!
38//! let _d = Dialog::new(lit!("Open settings"))
39//!     .content(|| {
40//!         DialogContent::new()
41//!             .title(lit!("Settings"))
42//!             .supporting_text(lit!("Adjust your preferences below."))
43//!     });
44//! ```
45//!
46//! ## Touch and pen
47//!
48//! The trigger is a `Button` (or, with `.trigger(..)`, the caller's widget wrapped
49//! in the same activation handlers), and both actuate on the release. The footer's
50//! buttons are buttons.
51//!
52//! The scrim is the one full-viewport node that has to receive exactly the presses
53//! that land on it, so it says `no_hit_slop` outright rather than relying on the
54//! slop pass's size formula to exclude it by arithmetic — see
55//! `scrim_hit_targeting_tests` below. Its dismissal is a tap, so it too waits for
56//! the release.
57
58use std::cell::Cell;
59use std::rc::Rc;
60
61use teksilo_canvas::{Rect, Size, SizeProposal};
62use teksilo_core::accessibility::AccessNodeBuilder;
63use teksilo_core::build_context::BuildContext;
64use teksilo_core::event::{EventResponse, Key, WidgetEvent};
65use teksilo_core::modal::{ModalCloseBehavior, ModalPresentation, ModalRequest};
66use teksilo_core::overlay::{OverlayDismissCallback, OverlayId};
67use teksilo_core::signal::{Prop, Signal};
68use teksilo_core::styles::{DialogStyleConfig, SharedDialogStyle};
69use teksilo_core::widget::{EventContext, LayoutContext, PendingChild, Widget, WidgetPlacement};
70use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
71use teksilo_core::widget_id::WidgetId;
72use teksilo_tokens::{TextRole, TextStyleRole};
73
74use crate::button::{Button, ButtonVariant};
75use crate::overlay_trigger::OverlayTrigger;
76use crate::primitives::{Divider, TextWidget, VStack};
77use teksilo_i18n::LocalizedString;
78
79type DialogFactory = std::rc::Rc<dyn Fn() -> Box<dyn Widget>>;
80
81/// Rounded panel chrome that wraps a modal dialog's content widget.
82///
83/// All visual dimensions (padding, corner radius, min-width, shadow) are owned
84/// by the active [`DialogStyle`](teksilo_core::styles::DialogStyle); per-instance
85/// overrides are available via [`Self::padding`] and [`Self::min_width`].
86pub struct ModalContainer {
87    content_id: Option<WidgetId>,
88    pending_content: Option<Box<dyn Widget>>,
89    padding_override: Option<f32>,
90    min_width_override: Option<f32>,
91    /// Explicit accessible title for the dialog. Set via `.title(...)`
92    /// — typically the same string the inner `DialogContent` uses as
93    /// its visual title. When `None`, `accessibility()` falls back to
94    /// the generic i18n `a11y_dialog_name` string so there's always
95    /// a non-empty name for screen readers.
96    /// AT name for the `Role::Dialog` node. Kept as a `LocalizedString`
97    /// (not eagerly resolved) so an explicit `.title(tr!(...))` follows a
98    /// live locale switch — `accessibility()` re-resolves on the AT
99    /// re-walk. The content-derived hint path is wrapped as a literal
100    /// (the core `accessible_title_hint` trait returns a plain `String`,
101    /// since core can't name `LocalizedString`); dialogs rebuild on show
102    /// so the hint is still current-locale at present time.
103    title: Option<LocalizedString>,
104    /// Per-call override for the modal panel chrome. Replaces the
105    /// theme-wide `style_slots.dialog` and the IntUI default
106    /// `RecipeDialogStyle` for just this container.
107    style_override: Option<SharedDialogStyle>,
108    /// Build state — the `DialogStyle::make_panel` root.
109    root_child_id: Option<WidgetId>,
110    /// True once the container has been wired to the title its content
111    /// paints. It must then set no name of its own: the consumer prefers a
112    /// node's own label over its `labelled_by` targets, so doing both would
113    /// silently drop the relation.
114    named_by_content: bool,
115    /// True when the content's own node is already a `Role::Dialog` or
116    /// `Role::AlertDialog` (a `MessageBox`, a `CommandPalette`). The
117    /// container then publishes no dialog of its own, see `accessibility`.
118    content_is_dialog: bool,
119}
120
121impl ModalContainer {
122    /// Wrap `content` inside a modal panel with default chrome.
123    pub fn new(content: impl Widget + 'static) -> Self {
124        Self::boxed(Box::new(content))
125    }
126
127    pub(crate) fn boxed(content: Box<dyn Widget>) -> Self {
128        Self {
129            content_id: None,
130            pending_content: Some(content),
131            padding_override: None,
132            min_width_override: None,
133            title: None,
134            style_override: None,
135            root_child_id: None,
136            named_by_content: false,
137            content_is_dialog: false,
138        }
139    }
140
141    /// Override the content padding (logical pixels) from the theme default.
142    pub fn padding(mut self, padding: f32) -> Self {
143        self.padding_override = Some(padding.max(0.0));
144        self
145    }
146
147    /// Override the minimum panel width (logical pixels) from the theme default.
148    pub fn min_width(mut self, min_width: f32) -> Self {
149        self.min_width_override = Some(min_width.max(0.0));
150        self
151    }
152
153    /// Per-call style override for the modal panel chrome. Replaces the
154    /// theme-wide default `DialogStyle` for just this container.
155    pub fn style(mut self, style: impl teksilo_core::styles::DialogStyle) -> Self {
156        self.style_override = Some(Rc::new(style));
157        self
158    }
159
160    /// Accessible title for the dialog, announced as the dialog's name
161    /// when the content paints no title of its own. Content that does —
162    /// e.g. `DialogContent::title` — names the dialog by pointing at that
163    /// label and wins over this string, so the two should match. Content
164    /// that is itself a dialog names itself, and the container then
165    /// publishes no node for this title to name.
166    pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
167        let ls: LocalizedString = title.into();
168        self.title = Some(ls);
169        self
170    }
171}
172
173impl std::fmt::Debug for ModalContainer {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        f.debug_struct("ModalContainer")
176            .field("padding_override", &self.padding_override)
177            .field("min_width_override", &self.min_width_override)
178            .finish()
179    }
180}
181
182impl Widget for ModalContainer {
183    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
184        if let Some(content) = self.pending_content.take() {
185            // Read the content's own role the way the overlay manager does
186            // when it decides what a host surface is: a content widget that
187            // is already a dialog names, describes and announces itself.
188            let mut probe = AccessNodeBuilder::new();
189            content.accessibility(&mut probe);
190            self.content_is_dialog = matches!(
191                probe.role(),
192                teksilo_core::accesskit::Role::Dialog | teksilo_core::accesskit::Role::AlertDialog
193            );
194            // If the caller didn't set an explicit `.title(...)`,
195            // ask the content widget for a suggested title — e.g.
196            // `DialogContent::accessible_title_hint` returns its
197            // own visible title. This lets dialogs announce their
198            // real name without forcing callers to duplicate the
199            // string at both the content and the container level.
200            if self.title.is_none()
201                && !self.content_is_dialog
202                && let Some(hint) = content.accessible_title_hint()
203            {
204                // The core `accessible_title_hint` trait can only return a
205                // plain `String`, so wrap it as a literal. Resolved fresh
206                // at present time (dialogs rebuild on show).
207                self.title = Some(LocalizedString::literal(hint));
208            }
209            let content_id = ctx.add_boxed(content);
210            self.content_id = Some(content_id);
211            // Name the dialog by pointing at the title it already paints,
212            // rather than by a second copy of the string. `build()` runs
213            // eagerly on insertion, so the content's title node exists by
214            // now; an explicit `.title(..)` on the container yields to it,
215            // because the visible title is what a reader will find when
216            // they go looking for the name they heard.
217            if !self.content_is_dialog
218                && let Some(title_id) = ctx.accessible_title_node(content_id)
219            {
220                let self_id = ctx.self_id();
221                ctx.access_labelled_by(self_id, title_id);
222                self.named_by_content = true;
223            }
224        }
225
226        // The panel chrome (rounded surface + border + content
227        // padding) is owned by the active `DialogStyle`; the modal
228        // mounting / dismissal pipeline stays on this widget.
229        let content_id = self
230            .content_id
231            .expect("ModalContainer requires content — none was set");
232        let style: SharedDialogStyle = self
233            .style_override
234            .clone()
235            .or_else(|| ctx.theme().style_slots.dialog.clone())
236            .unwrap_or_else(|| {
237                Rc::new(crate::styles::RecipeDialogStyle::for_tokens(
238                    &ctx.theme().input,
239                ))
240            });
241        let cfg = DialogStyleConfig {
242            content: content_id,
243            has_scrim: true,
244            padding_override: self.padding_override,
245            min_width_override: self.min_width_override,
246        };
247        let root_id = style.make_panel(&cfg, ctx);
248        self.root_child_id = Some(root_id);
249        vec![root_id]
250    }
251
252    fn layout_response(
253        &self,
254        proposal: SizeProposal,
255        ctx: &LayoutContext,
256    ) -> teksilo_core::widget::LayoutResponse {
257        self.root_child_id
258            .and_then(|id| ctx.child_size(id, proposal))
259            .unwrap_or_else(|| proposal.resolve(240.0, 120.0))
260            .into()
261    }
262
263    fn place_children(
264        &self,
265        bounds: Rect,
266        _proposal: SizeProposal,
267        children: &mut [WidgetPlacement],
268        _ctx: &LayoutContext,
269    ) {
270        for child in children.iter_mut() {
271            child.origin = bounds.origin();
272            child.size = bounds.size();
273        }
274    }
275
276    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
277        if self.content_is_dialog {
278            // The content is the dialog: a `MessageBox` is a modal, named
279            // and described `AlertDialog`, a `CommandPalette` a modal, named
280            // `Dialog`. A second dialog around it, named by the same title,
281            // put two dialogs on every path from a control inside to the
282            // window, and Orca speaks each named ancestor a focus change
283            // enters (speech_generator.py `_generateAncestors`; a dialog and
284            // an alert are never layout-only to it, script_utilities.py
285            // `isLayoutOnly` and `_topLevelRoles`), so the title was heard
286            // twice. A bare `GenericContainer` is dropped by every adapter
287            // with the content kept. A modal is a host surface by its
288            // placement, not by this role (`overlay_is_host_surface`).
289            builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
290            return;
291        }
292        builder.set_role(teksilo_core::accesskit::Role::Dialog);
293        // A container named through a relation must not also set a name:
294        // the consumer prefers the node's own label, so setting both would
295        // silently drop the relation and announce a stale copy.
296        if !self.named_by_content {
297            let name = self
298                .title
299                .as_ref()
300                .map(|t| t.resolve_now())
301                .unwrap_or_else(|| teksilo_i18n::tr_widget!(a11y_dialog_name()).resolve_now());
302            builder.set_name(name);
303        }
304        // ModalContainer is always modal — it's the one path that goes
305        // through `ModalRequest` / `ModalPresentation`. A dialog that
306        // doesn't block outside interaction would use `Popover` instead.
307        builder.set_modal();
308    }
309
310    fn children(&self) -> Vec<WidgetId> {
311        self.root_child_id.into_iter().collect()
312    }
313}
314
315/// Full-viewport dimming scrim painted behind a [`ModalContainer`].
316///
317/// Mounted by the modal-presentation pipeline (teksilo-app) as a separate
318/// `OverlayPlacement::FullViewport` overlay pushed BEFORE the centered
319/// modal overlay so it z-orders below the panel. The chrome itself is
320/// delegated to the active `DialogStyle::make_scrim`; clicking the
321/// scrim dismisses the linked modal when the modal's
322/// [`ModalCloseBehavior`] permits click-outside dismissal.
323///
324/// The dismissal cascade is wired via
325/// `OverlayManager::set_parent_overlay` AFTER both overlays are
326/// pushed — the scrim's `parent_overlay` is set to the modal's id, so
327/// any dismiss of the modal cascades through `dismiss_immediate` and
328/// also dismisses the scrim. The scrim's own `dismiss` behavior is
329/// `Manual` — it never dismisses itself directly.
330pub struct ModalScrim {
331    style_override: Option<SharedDialogStyle>,
332    /// Filled in by the framework AFTER the modal overlay is pushed
333    /// — the scrim is mounted FIRST (so it z-orders below the modal),
334    /// so the modal's `OverlayId` isn't yet known at build time. The
335    /// scrim's on-tap closure reads through this `Cell` at click time
336    /// rather than capturing a value that doesn't exist yet.
337    dismiss_target: Rc<Cell<Option<OverlayId>>>,
338    /// Whether clicking the scrim should dismiss `dismiss_target`.
339    /// Reflects the modal's [`ModalCloseBehavior`]: `true` for
340    /// `ClickOutside` and `EscapeOrClickOutside`; `false` for
341    /// `EscapeKey` and `Manual` (clicks on the dim are absorbed but
342    /// do not dismiss).
343    click_to_dismiss: bool,
344    root_child_id: Option<WidgetId>,
345}
346
347impl ModalScrim {
348    /// Build a new scrim; wire it with [`Self::dismiss_target`] and
349    /// [`Self::click_to_dismiss`] after construction.
350    pub fn new() -> Self {
351        Self {
352            style_override: None,
353            dismiss_target: Rc::new(Cell::new(None)),
354            click_to_dismiss: false,
355            root_child_id: None,
356        }
357    }
358
359    /// Per-call style override for the scrim chrome. Replaces the
360    /// theme-wide default `DialogStyle` for just this scrim.
361    pub fn style(mut self, style: impl teksilo_core::styles::DialogStyle) -> Self {
362        self.style_override = Some(Rc::new(style));
363        self
364    }
365
366    /// Handle to the modal-overlay id the scrim dismisses on click.
367    /// The framework fills this AFTER the modal is pushed (see the
368    /// in-tree modal pipeline in `teksilo-app`).
369    pub fn dismiss_target(mut self, target: Rc<Cell<Option<OverlayId>>>) -> Self {
370        self.dismiss_target = target;
371        self
372    }
373
374    /// Enable click-to-dismiss on the scrim. Should mirror whether the
375    /// modal's [`ModalCloseBehavior`] permits click-outside dismissal.
376    pub fn click_to_dismiss(mut self, enabled: bool) -> Self {
377        self.click_to_dismiss = enabled;
378        self
379    }
380}
381
382impl Default for ModalScrim {
383    fn default() -> Self {
384        Self::new()
385    }
386}
387
388impl std::fmt::Debug for ModalScrim {
389    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390        f.debug_struct("ModalScrim")
391            .field("click_to_dismiss", &self.click_to_dismiss)
392            .finish()
393    }
394}
395
396impl Widget for ModalScrim {
397    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
398        let style: SharedDialogStyle = self
399            .style_override
400            .clone()
401            .or_else(|| ctx.theme().style_slots.dialog.clone())
402            .unwrap_or_else(|| {
403                Rc::new(crate::styles::RecipeDialogStyle::for_tokens(
404                    &ctx.theme().input,
405                ))
406            });
407        let chrome_id = style.make_scrim(ctx);
408
409        // A scrim is never re-attributed to and never widens. The size formula
410        // already excludes a full-viewport node from the slop pass by
411        // arithmetic, but a scrim's whole contract is that it receives exactly
412        // the presses that land on it — saying so outright means the guarantee
413        // does not quietly depend on how large the scrim happens to be.
414        let mut handlers = HandlerSet::new().no_hit_slop();
415        if self.click_to_dismiss {
416            let target = self.dismiss_target.clone();
417            handlers = handlers.on_tap(move |_event, ctx| {
418                if let Some(modal_id) = target.get() {
419                    ctx.dismiss_overlay(modal_id);
420                }
421            });
422        }
423        ctx.apply_self_handlers(handlers);
424
425        self.root_child_id = Some(chrome_id);
426        vec![chrome_id]
427    }
428
429    fn layout_response(
430        &self,
431        proposal: SizeProposal,
432        ctx: &LayoutContext,
433    ) -> teksilo_core::widget::LayoutResponse {
434        // The scrim's actual size is determined by
435        // `OverlayPlacement::FullViewport` in `position_overlays`,
436        // which overrides the intrinsic size to the full viewport. We
437        // still report the child's wanted size so the proposal flows
438        // correctly when the framework probes the intrinsic size.
439        self.root_child_id
440            .and_then(|id| ctx.child_size(id, proposal))
441            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
442            .into()
443    }
444
445    fn place_children(
446        &self,
447        bounds: Rect,
448        _proposal: SizeProposal,
449        children: &mut [WidgetPlacement],
450        _ctx: &LayoutContext,
451    ) {
452        for child in children.iter_mut() {
453            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
454            child.size = Size::new(bounds.width, bounds.height);
455        }
456    }
457
458    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
459        // Hidden from the AT: the modal panel above carries the
460        // `Role::Dialog` node with the accessible name.
461        builder.set_hidden();
462    }
463
464    fn children(&self) -> Vec<WidgetId> {
465        self.root_child_id.into_iter().collect()
466    }
467}
468
469fn queue_dialog_request(
470    ctx: &mut EventContext,
471    factory: &DialogFactory,
472    presentation: ModalPresentation,
473    close_behavior: ModalCloseBehavior,
474    title: &str,
475    on_dismiss: Option<OverlayDismissCallback>,
476) {
477    let factory = factory.clone();
478    let mut request = ModalRequest::deferred(move |tree| {
479        let content = (factory.as_ref())();
480        tree.add(ModalContainer::boxed(content))
481    })
482    .presentation(presentation)
483    .close_behavior(close_behavior)
484    .title(title)
485    .size(460, 260);
486    if let Some(cb) = on_dismiss {
487        request = request.on_dismiss(cb);
488    }
489    ctx.present_modal(request);
490}
491
492/// Convenience body layout for a modal dialog: optional title, supporting text,
493/// scrollable body slot, and a `Divider`-separated footer row.
494pub struct DialogContent {
495    title: Option<LocalizedString>,
496    supporting_text: Option<LocalizedString>,
497    pending_body: Option<PendingChild>,
498    pending_footer: Option<PendingChild>,
499    root_child_id: Option<WidgetId>,
500    /// The label that paints the title, handed to the enclosing container
501    /// so it can name itself by pointing at it.
502    title_node: Option<WidgetId>,
503}
504
505impl DialogContent {
506    /// Create an empty dialog body with no sections set.
507    pub fn new() -> Self {
508        Self {
509            title: None,
510            supporting_text: None,
511            pending_body: None,
512            pending_footer: None,
513            root_child_id: None,
514            title_node: None,
515        }
516    }
517
518    /// Bold title shown at the top of the content area. Also propagated to
519    /// the enclosing `ModalContainer` via `accessible_title_hint`.
520    pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
521        self.title = Some(title.into());
522        self
523    }
524
525    /// Secondary description text shown below the title.
526    pub fn supporting_text(mut self, text: impl Into<LocalizedString>) -> Self {
527        self.supporting_text = Some(text.into());
528        self
529    }
530
531    /// Main scrollable content slot (any widget).
532    pub fn body(mut self, body: impl teksilo_core::IntoTeksiChild) -> Self {
533        self.pending_body = Some(teksilo_core::IntoTeksiChild::into_pending(body));
534        self
535    }
536
537    /// Footer slot separated from the body by a `Divider` (typically action
538    /// buttons like "OK" / "Cancel").
539    pub fn footer(mut self, footer: impl teksilo_core::IntoTeksiChild) -> Self {
540        self.pending_footer = Some(teksilo_core::IntoTeksiChild::into_pending(footer));
541        self
542    }
543}
544
545impl Default for DialogContent {
546    fn default() -> Self {
547        Self::new()
548    }
549}
550
551impl std::fmt::Debug for DialogContent {
552    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
553        f.debug_struct("DialogContent")
554            .field("title", &self.title)
555            .field("supporting_text", &self.supporting_text)
556            .finish()
557    }
558}
559
560impl Widget for DialogContent {
561    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
562        let mut stack = VStack::new().spacing(16.0);
563
564        if self.title.is_some() || self.supporting_text.is_some() {
565            let mut header = VStack::new().spacing(8.0);
566            if let Some(title) = self.title.clone() {
567                // Kept by id, not by value: the enclosing container names
568                // itself by pointing at this node rather than copying its
569                // string, so the title stays a label a reader can review by
570                // character in its own right.
571                let title_id = ctx.add(
572                    TextWidget::new(title)
573                        .style(TextStyleRole::BodyBold)
574                        .color(TextRole::Primary)
575                        .single_line(),
576                );
577                self.title_node = Some(title_id);
578                header = header.child(title_id);
579            }
580            if let Some(text) = self.supporting_text.clone() {
581                header = header.child(
582                    TextWidget::new(text)
583                        .style(TextStyleRole::Body)
584                        .color(TextRole::Secondary),
585                );
586            }
587            let header_id = ctx.add(header);
588            stack = stack.child(header_id);
589        }
590
591        if let Some(body) = self.pending_body.take() {
592            let body_id = match body {
593                PendingChild::Id(id) => id,
594                PendingChild::Deferred(w) => ctx.add_boxed(w),
595            };
596            stack = stack.child(body_id);
597        }
598
599        if let Some(footer) = self.pending_footer.take() {
600            let divider_id = ctx.add(Divider::new());
601            let footer_id = match footer {
602                PendingChild::Id(id) => id,
603                PendingChild::Deferred(w) => ctx.add_boxed(w),
604            };
605            stack = stack.child(divider_id).child(footer_id);
606        }
607
608        let root = ctx.add(stack);
609        self.root_child_id = Some(root);
610        vec![root]
611    }
612
613    fn layout_response(
614        &self,
615        proposal: SizeProposal,
616        ctx: &LayoutContext,
617    ) -> teksilo_core::widget::LayoutResponse {
618        self.root_child_id
619            .and_then(|id| ctx.child_size(id, proposal))
620            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
621            .into()
622    }
623
624    fn place_children(
625        &self,
626        bounds: Rect,
627        _proposal: SizeProposal,
628        children: &mut [WidgetPlacement],
629        _ctx: &LayoutContext,
630    ) {
631        for child in children.iter_mut() {
632            child.origin = bounds.origin();
633            child.size = bounds.size();
634        }
635    }
636
637    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
638        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
639    }
640
641    /// Expose the visible title to an enclosing `ModalContainer`
642    /// (or any other shell) so it can use it as its own accessible
643    /// name without the caller having to thread the same string
644    /// through twice.
645    fn accessible_title_node(&self) -> Option<WidgetId> {
646        self.title_node
647    }
648
649    fn accessible_title_hint(&self) -> Option<String> {
650        self.title.as_ref().map(|t| t.resolve_now())
651    }
652
653    fn children(&self) -> Vec<WidgetId> {
654        self.root_child_id.into_iter().collect()
655    }
656}
657
658/// A trigger button that presents a modal dialog when activated.
659///
660/// Renders as a `Button` by default; call `.trigger(w)` to replace it with any
661/// widget. The content is lazily constructed by a factory closure each time the
662/// dialog opens — no persistent widget subtree is kept while the dialog is closed.
663pub struct Dialog {
664    label: LocalizedString,
665    variant: ButtonVariant,
666    /// Enabled state, static or reactive; forwarded to the trigger at
667    /// build time.
668    enabled: Prop<bool>,
669    presentation: ModalPresentation,
670    close_behavior: ModalCloseBehavior,
671    content_factory: Option<DialogFactory>,
672    pending_trigger: Option<PendingChild>,
673    root_child_id: Option<WidgetId>,
674}
675
676impl Dialog {
677    /// Build a dialog trigger with `label` as the button text and accessible name.
678    pub fn new(label: impl Into<LocalizedString>) -> Self {
679        Self {
680            label: label.into(),
681            variant: ButtonVariant::Filled,
682            enabled: Prop::Static(true),
683            presentation: ModalPresentation::Auto,
684            close_behavior: ModalCloseBehavior::EscapeOrClickOutside,
685            content_factory: None,
686            pending_trigger: None,
687            root_child_id: None,
688        }
689    }
690
691    /// Factory closure that builds the dialog's content each time it opens.
692    /// Required — the dialog panics at build time if no factory is set.
693    pub fn content<W, F>(mut self, factory: F) -> Self
694    where
695        W: Widget + 'static,
696        F: Fn() -> W + 'static,
697    {
698        self.content_factory = Some(std::rc::Rc::new(move || {
699            Box::new(factory()) as Box<dyn Widget>
700        }));
701        self
702    }
703
704    /// Visual style of the default trigger button. Has no effect when
705    /// `.trigger(…)` replaces the button with a custom widget.
706    pub fn variant(mut self, variant: ButtonVariant) -> Self {
707        self.variant = variant;
708        self
709    }
710
711    /// Enable or disable the trigger button, statically or reactively
712    /// (default `true`).
713    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
714        self.enabled = enabled.into();
715        self
716    }
717
718    /// Override the modal presentation mode (default `ModalPresentation::Auto`).
719    pub fn presentation(mut self, presentation: ModalPresentation) -> Self {
720        self.presentation = presentation;
721        self
722    }
723
724    /// Override how the dialog may be closed (default `EscapeOrClickOutside`).
725    pub fn close_behavior(mut self, close_behavior: ModalCloseBehavior) -> Self {
726        self.close_behavior = close_behavior;
727        self
728    }
729
730    /// Replace the default `Button` trigger with a custom widget. The widget
731    /// receives the same tap / key / AT-action handlers as the button would.
732    pub fn trigger(mut self, trigger: impl teksilo_core::IntoTeksiChild) -> Self {
733        self.pending_trigger = Some(teksilo_core::IntoTeksiChild::into_pending(trigger));
734        self
735    }
736}
737
738impl std::fmt::Debug for Dialog {
739    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
740        f.debug_struct("Dialog")
741            .field("label", &self.label)
742            .field("style", &self.variant)
743            .field("enabled", &self.enabled.get())
744            .finish()
745    }
746}
747
748impl Widget for Dialog {
749    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
750        let label = self.label.clone();
751        // Live signal view of the enabled state — the manual gates below
752        // run inside event closures dispatched later, so a plain `bool`
753        // snapshot captured here would go stale for a `Prop::Bound`
754        // value. `.as_signal()` returns the underlying signal when bound,
755        // or wraps a static value in a fresh `Signal::new(v)`.
756        let enabled = self.enabled.as_signal();
757        let close_behavior = self.close_behavior;
758        let presentation = self.presentation;
759        let style = self.variant;
760        let content_factory = self
761            .content_factory
762            .clone()
763            .expect("Dialog requires .content(...) — no content factory was set");
764
765        // Track whether the modal is currently open so the trigger can set
766        // aria-expanded correctly. The dismiss callback resets it to false
767        // regardless of which close path fires (Escape, click-outside, explicit
768        // ctx.dismiss_modal()). Only in-tree presentations fire this callback.
769        let is_open: Signal<bool> = ctx.signal(false);
770        let dismiss_callback: OverlayDismissCallback = {
771            let is_open = is_open.clone();
772            std::rc::Rc::new(move |_, _| {
773                is_open.set(false);
774            })
775        };
776
777        let root_id = if let Some(trigger) = self.pending_trigger.take() {
778            // One opener for all three routes. `OverlayTrigger` puts the tap on
779            // the wrapped widget and Enter/Space and the AT `Click` on whichever
780            // node takes focus, which is also the node carrying the name.
781            let open = {
782                let label = label.clone();
783                let content_factory = content_factory.clone();
784                let enabled = enabled.clone();
785                let is_open = is_open.clone();
786                let dismiss = dismiss_callback.clone();
787                move |ctx: &mut teksilo_core::widget::EventContext| {
788                    if !enabled.get() {
789                        return;
790                    }
791                    is_open.set(true);
792                    queue_dialog_request(
793                        ctx,
794                        &content_factory,
795                        presentation,
796                        close_behavior,
797                        &label.resolve_now(),
798                        Some(dismiss.clone()),
799                    );
800                }
801            };
802            let overlay_trigger = OverlayTrigger::from_pending(trigger)
803                .on_activate(open)
804                .activate_on_key_up()
805                .enabled(self.enabled.clone())
806                .name(label)
807                .has_popup(teksilo_core::accesskit::HasPopup::Dialog)
808                .expanded_when(is_open.clone());
809            ctx.add(overlay_trigger)
810        } else {
811            let tap_open = is_open.clone();
812            let tap_dismiss = dismiss_callback.clone();
813            let key_open = is_open.clone();
814            let key_dismiss = dismiss_callback.clone();
815            let action_open = is_open.clone();
816            let action_dismiss = dismiss_callback.clone();
817            ctx.add(
818                Button::new(label)
819                    .variant(style)
820                    .enabled(enabled.clone())
821                    .has_popup(teksilo_core::accesskit::HasPopup::Dialog)
822                    .expanded_when(is_open.clone())
823                    .on_tap({
824                        let label = self.label.clone();
825                        let content_factory = content_factory.clone();
826                        let enabled = enabled.clone();
827                        move |_pos, ctx| {
828                            if !enabled.get() {
829                                return;
830                            }
831                            tap_open.set(true);
832                            queue_dialog_request(
833                                ctx,
834                                &content_factory,
835                                presentation,
836                                close_behavior,
837                                &label.resolve_now(),
838                                Some(tap_dismiss.clone()),
839                            );
840                        }
841                    })
842                    .on_key({
843                        let label = self.label.clone();
844                        let content_factory = content_factory.clone();
845                        let enabled = enabled.clone();
846                        move |event, ctx| match event {
847                            WidgetEvent::KeyUp {
848                                key: Key::Enter | Key::Space,
849                                ..
850                            } if enabled.get() => {
851                                key_open.set(true);
852                                queue_dialog_request(
853                                    ctx,
854                                    &content_factory,
855                                    presentation,
856                                    close_behavior,
857                                    &label.resolve_now(),
858                                    Some(key_dismiss.clone()),
859                                );
860                                EventResponse::Handled
861                            }
862                            _ => EventResponse::Ignored,
863                        }
864                    })
865                    .on_access_action({
866                        let label = self.label.clone();
867                        let content_factory = content_factory.clone();
868                        let enabled = enabled.clone();
869                        move |action, ctx| {
870                            if action == teksilo_core::accesskit::Action::Click && enabled.get() {
871                                action_open.set(true);
872                                queue_dialog_request(
873                                    ctx,
874                                    &content_factory,
875                                    presentation,
876                                    close_behavior,
877                                    &label.resolve_now(),
878                                    Some(action_dismiss.clone()),
879                                );
880                                EventResponse::Handled
881                            } else {
882                                EventResponse::Ignored
883                            }
884                        }
885                    }),
886            )
887        };
888
889        self.root_child_id = Some(root_id);
890        vec![root_id]
891    }
892
893    fn layout_response(
894        &self,
895        proposal: SizeProposal,
896        ctx: &LayoutContext,
897    ) -> teksilo_core::widget::LayoutResponse {
898        self.root_child_id
899            .and_then(|id| ctx.child_size(id, proposal))
900            .unwrap_or_else(|| proposal.resolve(140.0, 40.0))
901            .into()
902    }
903
904    fn place_children(
905        &self,
906        bounds: Rect,
907        _proposal: SizeProposal,
908        children: &mut [WidgetPlacement],
909        _ctx: &LayoutContext,
910    ) {
911        for child in children.iter_mut() {
912            child.origin = bounds.origin();
913            child.size = bounds.size();
914        }
915    }
916
917    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
918        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
919    }
920
921    fn children(&self) -> Vec<WidgetId> {
922        self.root_child_id.into_iter().collect()
923    }
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929    use teksilo_canvas::Size;
930    use teksilo_core::widget_tree::WidgetTree;
931    use teksilo_core::{ModalContent, ModalPresentation};
932    use teksilo_i18n::lit;
933
934    #[derive(Debug)]
935    struct FixedLeaf(f32, f32);
936
937    impl Widget for FixedLeaf {
938        fn layout_response(
939            &self,
940            _proposal: SizeProposal,
941            _ctx: &LayoutContext,
942        ) -> teksilo_core::widget::LayoutResponse {
943            Size::new(self.0, self.1).into()
944        }
945    }
946
947    #[test]
948    fn access_click_opens_centered_dialog_overlay() {
949        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
950        tree.add(Dialog::new(lit!("Open dialog")).content(|| FixedLeaf(220.0, 120.0)));
951        tree.layout(SizeProposal::exact(800.0, 600.0));
952
953        let trigger = tree.find_by_label("Open dialog").unwrap();
954        tree.dispatch_event(WidgetEvent::AccessAction {
955            action: teksilo_core::accesskit::Action::Click,
956            target: Some(trigger),
957            target_node: teksilo_core::accessibility::root_node_id(),
958            data: None,
959        });
960
961        let requests = tree.drain_pending_modal_requests();
962        assert_eq!(requests.len(), 1);
963        assert_eq!(requests[0].request.presentation, ModalPresentation::Auto);
964        assert_eq!(
965            requests[0].request.close_behavior,
966            ModalCloseBehavior::EscapeOrClickOutside,
967        );
968        assert!(matches!(
969            requests[0].request.content,
970            ModalContent::Deferred(_)
971        ));
972    }
973
974    #[test]
975    fn dialog_surface_exposes_dialog_role() {
976        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
977        tree.add(Dialog::new(lit!("Open dialog")).content(|| FixedLeaf(220.0, 120.0)));
978        tree.layout(SizeProposal::exact(800.0, 600.0));
979
980        let trigger = tree.find_by_label("Open dialog").unwrap();
981        tree.dispatch_event(WidgetEvent::AccessAction {
982            action: teksilo_core::accesskit::Action::Click,
983            target: Some(trigger),
984            target_node: teksilo_core::accessibility::root_node_id(),
985            data: None,
986        });
987
988        let request = tree.drain_pending_modal_requests().pop().unwrap().request;
989        let content_id = match request.content {
990            ModalContent::Deferred(builder) => builder(&mut tree),
991            ModalContent::ExistingWidget(_) => {
992                unreachable!("dialog now always uses deferred content")
993            }
994        };
995        tree.layout(SizeProposal::exact(800.0, 600.0));
996
997        let dialog = tree
998            .find_by_role(teksilo_core::accesskit::Role::Dialog)
999            .unwrap();
1000        let info = tree.accessibility_node(dialog);
1001        assert_eq!(info.role(), teksilo_core::accesskit::Role::Dialog);
1002        assert!(tree.bounds(content_id).width > 0.0);
1003    }
1004
1005    /// The name an adapter would announce for `id`, resolved the way the
1006    /// consumer resolves it — through `labelled_by` when the node carries
1007    /// no name of its own.
1008    fn announced_name(tree: &mut WidgetTree, id: WidgetId) -> Option<String> {
1009        let update = tree.sync_accessibility();
1010        let target = teksilo_core::accessibility::widget_id_to_node_id(id);
1011        let consumer = accesskit_consumer::Tree::new(update, false);
1012        let state = consumer.state();
1013        let mut stack = vec![state.root()];
1014        while let Some(node) = stack.pop() {
1015            if node.locate().0 == target {
1016                return node.label();
1017            }
1018            for child in node.children() {
1019                stack.push(child);
1020            }
1021        }
1022        None
1023    }
1024
1025    #[test]
1026    fn modal_container_is_named_by_the_title_its_content_paints() {
1027        // The container points at the title label rather than copying its
1028        // string, so the title is announced once and stays a label a reader
1029        // can find and review by character.
1030        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1031        let container = tree.add(ModalContainer::new(
1032            DialogContent::new()
1033                .title(lit!("Delete file?"))
1034                .body(FixedLeaf(100.0, 40.0)),
1035        ));
1036        tree.layout(SizeProposal::exact(600.0, 400.0));
1037        assert_eq!(
1038            tree.accessibility_node(container).role(),
1039            teksilo_core::accesskit::Role::Dialog
1040        );
1041        assert_eq!(
1042            announced_name(&mut tree, container).as_deref(),
1043            Some("Delete file?")
1044        );
1045    }
1046
1047    #[test]
1048    fn an_explicit_title_yields_to_the_one_on_screen() {
1049        // `.title(..)` on the container used to win. It no longer does: a
1050        // name a reader hears but cannot find anywhere on screen is worse
1051        // than one they can, and the two are only ever meant to be the same
1052        // string. The explicit title still stands when the content paints
1053        // no title of its own.
1054        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1055        let container = tree.add(
1056            ModalContainer::new(
1057                DialogContent::new()
1058                    .title(lit!("Inner title"))
1059                    .body(FixedLeaf(100.0, 40.0)),
1060            )
1061            .title(lit!("Outer title")),
1062        );
1063        tree.layout(SizeProposal::exact(600.0, 400.0));
1064        assert_eq!(
1065            announced_name(&mut tree, container).as_deref(),
1066            Some("Inner title")
1067        );
1068
1069        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1070        let untitled = tree.add(
1071            ModalContainer::new(DialogContent::new().body(FixedLeaf(100.0, 40.0)))
1072                .title(lit!("Outer title")),
1073        );
1074        tree.layout(SizeProposal::exact(600.0, 400.0));
1075        assert_eq!(
1076            announced_name(&mut tree, untitled).as_deref(),
1077            Some("Outer title")
1078        );
1079    }
1080
1081    /// A panel that directs initial focus to its *second* child.
1082    ///
1083    /// The shape real dialogs have: the first focusable descendant is the
1084    /// close button in the title strip, and focus must land on the first form
1085    /// field instead — otherwise the dialog opens focused on "dismiss me" and
1086    /// swallows whatever the user types first.
1087    #[derive(Debug)]
1088    struct HintingPanel {
1089        first: Rc<std::cell::Cell<Option<WidgetId>>>,
1090        hinted: Rc<std::cell::Cell<Option<WidgetId>>>,
1091    }
1092
1093    impl Widget for HintingPanel {
1094        fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1095            let first = ctx.add(FixedLeaf(40.0, 20.0));
1096            let hinted = ctx.add(FixedLeaf(40.0, 20.0));
1097            self.first.set(Some(first));
1098            self.hinted.set(Some(hinted));
1099            vec![first, hinted]
1100        }
1101
1102        fn initial_focus_hint(&self) -> Option<WidgetId> {
1103            self.hinted.get()
1104        }
1105
1106        fn layout_response(
1107            &self,
1108            _proposal: SizeProposal,
1109            _ctx: &LayoutContext,
1110        ) -> teksilo_core::widget::LayoutResponse {
1111            Size::new(220.0, 120.0).into()
1112        }
1113    }
1114
1115    /// Wrapping content in a `ModalContainer` must not cost it the ability to
1116    /// direct initial focus.
1117    ///
1118    /// `ModalContainer` does **not** override `initial_focus_hint`, and does not
1119    /// need to: `WidgetTree::widget_initial_focus_hint` walks the subtree and
1120    /// finds the content's own hint through the container and its chrome panel.
1121    /// Nothing pinned that before, which made it look like a missing feature
1122    /// rather than a load-bearing one — and an app about to move a dozen
1123    /// hand-chromed panels onto `ModalContainer` is betting on it.
1124    ///
1125    /// If that walk is ever flattened to "ask the content root, then give up",
1126    /// every wrapped dialog silently reopens focused on its close button.
1127    #[test]
1128    fn modal_container_lets_its_content_direct_initial_focus() {
1129        let first = Rc::new(std::cell::Cell::new(None));
1130        let hinted = Rc::new(std::cell::Cell::new(None));
1131
1132        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1133        let container = tree.add(ModalContainer::new(HintingPanel {
1134            first: first.clone(),
1135            hinted: hinted.clone(),
1136        }));
1137        tree.layout(SizeProposal::exact(600.0, 400.0));
1138
1139        let target = tree.widget_initial_focus_hint(container);
1140        assert_eq!(
1141            target,
1142            hinted.get(),
1143            "the content's hint must survive being wrapped in a ModalContainer"
1144        );
1145        assert_ne!(
1146            target,
1147            first.get(),
1148            "…and must not fall back to the first descendant, which is the \
1149             close button in a real dialog"
1150        );
1151    }
1152
1153    /// The other half: the walk reports a hint, it does not invent one. Content
1154    /// with nothing to say leaves the pipeline free to fall through to
1155    /// `first_focusable_descendant`.
1156    #[test]
1157    fn modal_container_without_a_hint_reports_none() {
1158        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1159        let container = tree.add(ModalContainer::new(FixedLeaf(220.0, 120.0)));
1160        tree.layout(SizeProposal::exact(600.0, 400.0));
1161
1162        assert_eq!(tree.widget_initial_focus_hint(container), None);
1163    }
1164
1165    #[test]
1166    fn modal_container_preserves_shell_sizing_defaults() {
1167        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1168        let container = tree.add(ModalContainer::new(FixedLeaf(220.0, 120.0)));
1169        tree.layout(SizeProposal {
1170            width: None,
1171            height: None,
1172        });
1173
1174        // DialogStyle defaults: 24 dp content_padding, 280 dp min_width.
1175        // Content 220×120 + 48 padding = 268×168, clamped to 280×168.
1176        let bounds = tree.bounds(container);
1177        assert!((bounds.width - 280.0).abs() < 0.01);
1178        assert!((bounds.height - 168.0).abs() < 0.01);
1179    }
1180
1181    #[test]
1182    fn modal_container_custom_padding_changes_layout() {
1183        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1184        let container = tree.add(
1185            ModalContainer::new(FixedLeaf(220.0, 120.0))
1186                .padding(12.0)
1187                .min_width(200.0),
1188        );
1189        tree.layout(SizeProposal {
1190            width: None,
1191            height: None,
1192        });
1193
1194        let bounds = tree.bounds(container);
1195        assert!((bounds.width - 244.0).abs() < 0.01);
1196        assert!((bounds.height - 144.0).abs() < 0.01);
1197    }
1198
1199    #[test]
1200    fn custom_trigger_opens_dialog_overlay() {
1201        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1202        tree.add(
1203            Dialog::new(lit!("Open dialog"))
1204                .content(|| FixedLeaf(220.0, 120.0))
1205                .trigger(FixedLeaf(140.0, 40.0)),
1206        );
1207        tree.layout(SizeProposal::exact(800.0, 600.0));
1208
1209        // The OverlayTrigger now routes its handlers onto the trigger
1210        // child (so real `Button` triggers, which install their own
1211        // gesture arena, can't consume the tap before the opener
1212        // fires). Clicking the wrapper hit-tests into the child, which
1213        // is where the handler lives.
1214        let trigger = tree.find_by_label("Open dialog").unwrap();
1215        tree.click(trigger);
1216
1217        assert_eq!(tree.drain_pending_modal_requests().len(), 1);
1218    }
1219
1220    #[test]
1221    fn a_custom_trigger_advertises_and_answers_the_at_click() {
1222        // Finding 2. The handler existed and worked when hand-invoked, but it
1223        // sat on the child while `Role::Button` sat on the wrapper, and the
1224        // wrapper advertised no actions at all: a node that reads as a
1225        // well-formed, named button and that no screen reader can press.
1226        // Both halves are asserted, because either alone still leaves it
1227        // unusable.
1228        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1229        tree.add(
1230            Dialog::new(lit!("Open dialog"))
1231                .content(|| FixedLeaf(220.0, 120.0))
1232                .trigger(FixedLeaf(140.0, 40.0)),
1233        );
1234        tree.layout(SizeProposal::exact(800.0, 600.0));
1235
1236        let trigger = tree.find_by_label("Open dialog").unwrap();
1237        assert_eq!(
1238            tree.accessibility_node(trigger).role(),
1239            teksilo_core::accesskit::Role::Button
1240        );
1241        assert!(
1242            tree.accessibility_node(trigger)
1243                .actions()
1244                .contains(&teksilo_core::accesskit::Action::Click),
1245            "the node carrying Role::Button must advertise Click"
1246        );
1247
1248        // …and invoking it on THAT node — the one an adapter would target —
1249        // must actually open the dialog. Dispatched through the same entry
1250        // point the platform adapters use.
1251        let handled = tree.dispatch_access_action(
1252            teksilo_core::accessibility::widget_id_to_node_id(trigger),
1253            teksilo_core::accesskit::Action::Click,
1254            None,
1255            &mut teksilo_core::NoopWindowOps,
1256        );
1257        assert!(handled, "the AT Click must be reported as handled");
1258        assert_eq!(tree.drain_pending_modal_requests().len(), 1);
1259    }
1260
1261    #[test]
1262    fn rebuilding_a_custom_trigger_does_not_stack_its_at_handler() {
1263        // `OverlayTrigger` installs the AT route on itself on every build,
1264        // and `EventHandlers` MERGES access handlers rather than replacing
1265        // them — so a trigger rebuilt three times could plausibly open three
1266        // dialogs from one AT click. It does not; this is what says so.
1267        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1268        let d = tree.add(
1269            Dialog::new(lit!("Open dialog"))
1270                .content(|| FixedLeaf(220.0, 120.0))
1271                .trigger(FixedLeaf(140.0, 40.0)),
1272        );
1273        tree.layout(SizeProposal::exact(800.0, 600.0));
1274        for _ in 0..3 {
1275            tree.arena_mark_needs_rebuild_for_testing(d);
1276            tree.layout(SizeProposal::exact(800.0, 600.0));
1277        }
1278        let trigger = tree.find_by_label("Open dialog").unwrap();
1279        tree.dispatch_access_action(
1280            teksilo_core::accessibility::widget_id_to_node_id(trigger),
1281            teksilo_core::accesskit::Action::Click,
1282            None,
1283            &mut teksilo_core::NoopWindowOps,
1284        );
1285        assert_eq!(tree.drain_pending_modal_requests().len(), 1);
1286    }
1287
1288    #[test]
1289    fn dialog_content_helper_builds_dialog_sections() {
1290        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1291        tree.add(Dialog::new(lit!("Open dialog")).content(|| {
1292            DialogContent::new()
1293                .title(lit!("Review Changes"))
1294                .supporting_text(lit!("Confirm the staged updates before continuing."))
1295                .body(FixedLeaf(220.0, 120.0))
1296                .footer(Button::new(lit!("Close")))
1297        }));
1298        tree.layout(SizeProposal::exact(800.0, 600.0));
1299
1300        let trigger = tree.find_by_label("Open dialog").unwrap();
1301        tree.dispatch_event(WidgetEvent::AccessAction {
1302            action: teksilo_core::accesskit::Action::Click,
1303            target: Some(trigger),
1304            target_node: teksilo_core::accessibility::root_node_id(),
1305            data: None,
1306        });
1307
1308        let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1309        match request.content {
1310            ModalContent::Deferred(builder) => {
1311                builder(&mut tree);
1312            }
1313            ModalContent::ExistingWidget(_) => {
1314                unreachable!("dialog now always uses deferred content")
1315            }
1316        }
1317        tree.layout(SizeProposal::exact(800.0, 600.0));
1318
1319        assert!(tree.find_by_label("Review Changes").is_some());
1320        assert!(tree.find_by_label("Close").is_some());
1321    }
1322
1323    #[test]
1324    fn dialog_presentation_can_be_overridden() {
1325        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1326        tree.add(
1327            Dialog::new(lit!("Open dialog"))
1328                .content(|| FixedLeaf(220.0, 120.0))
1329                .presentation(ModalPresentation::InTree),
1330        );
1331        tree.layout(SizeProposal::exact(800.0, 600.0));
1332
1333        let trigger = tree.find_by_label("Open dialog").unwrap();
1334        tree.dispatch_event(WidgetEvent::AccessAction {
1335            action: teksilo_core::accesskit::Action::Click,
1336            target: Some(trigger),
1337            target_node: teksilo_core::accessibility::root_node_id(),
1338            data: None,
1339        });
1340
1341        let requests = tree.drain_pending_modal_requests();
1342        assert_eq!(requests.len(), 1);
1343        assert_eq!(requests[0].request.presentation, ModalPresentation::InTree);
1344    }
1345
1346    #[test]
1347    fn dialog_close_behavior_can_be_overridden() {
1348        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1349        tree.add(
1350            Dialog::new(lit!("Open dialog"))
1351                .content(|| FixedLeaf(220.0, 120.0))
1352                .close_behavior(ModalCloseBehavior::Manual),
1353        );
1354        tree.layout(SizeProposal::exact(800.0, 600.0));
1355
1356        let trigger = tree.find_by_label("Open dialog").unwrap();
1357        tree.dispatch_event(WidgetEvent::AccessAction {
1358            action: teksilo_core::accesskit::Action::Click,
1359            target: Some(trigger),
1360            target_node: teksilo_core::accessibility::root_node_id(),
1361            data: None,
1362        });
1363
1364        let requests = tree.drain_pending_modal_requests();
1365        assert_eq!(requests.len(), 1);
1366        assert_eq!(
1367            requests[0].request.close_behavior,
1368            ModalCloseBehavior::Manual
1369        );
1370    }
1371
1372    #[test]
1373    #[should_panic(expected = "Dialog requires .content(...)")]
1374    fn dialog_without_content_panics_on_build() {
1375        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1376        tree.add(Dialog::new(lit!("Open dialog")));
1377        tree.layout(SizeProposal::exact(800.0, 600.0));
1378    }
1379}
1380
1381#[cfg(test)]
1382mod scrim_hit_targeting_tests {
1383    use super::*;
1384    use teksilo_canvas::Point;
1385    use teksilo_core::pointer::{EventTime, PointerId, PointerInfo};
1386    use teksilo_core::widget_tree::WidgetTree;
1387    use teksilo_tokens::TargetDensity;
1388
1389    /// A scrim never participates in hit widening: it receives exactly the
1390    /// presses that land on it.
1391    ///
1392    /// A real scrim fills the viewport, and the slop pass's size formula
1393    /// already excludes anything that large by arithmetic. The point of the
1394    /// explicit `no_hit_slop` — and of shrinking the scrim here to make it
1395    /// observable — is that the guarantee must not depend on how big the scrim
1396    /// happens to be.
1397    #[test]
1398    fn a_modal_scrim_never_participates_in_hit_widening() {
1399        use crate::primitives::{FixedSize, ZStack};
1400        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1401        tree.set_input_density(TargetDensity::Touch);
1402        let scrim = tree.add(
1403            FixedSize::new()
1404                .width(20.0)
1405                .height(20.0)
1406                .child(ModalScrim::new().click_to_dismiss(true)),
1407        );
1408        tree.add(ZStack::new().child(scrim));
1409        tree.layout(SizeProposal::exact(200.0, 200.0));
1410
1411        let b = tree.bounds(scrim);
1412        assert_eq!(
1413            b.size(),
1414            Size::new(20.0, 20.0),
1415            "the fixture needs a SMALL scrim"
1416        );
1417        let finger = PointerInfo::touch(PointerId::MOUSE, EventTime::ZERO);
1418        // 4 dp outside, well inside the 8 dp a 20 dp node would otherwise earn
1419        // at Touch. Nothing in the scrim's subtree may claim it.
1420        let probe = Point::new(b.right() + 4.0, b.center().y);
1421        let hit = tree.hit_test_for(probe, &finger);
1422        let claimed_by_scrim = hit.is_some_and(|id| {
1423            std::iter::successors(Some(id), |id| tree.parent(*id)).any(|id| id == scrim)
1424        });
1425        assert!(
1426            !claimed_by_scrim,
1427            "a press outside the scrim reached {hit:?}, inside the scrim subtree"
1428        );
1429    }
1430}