Skip to main content

teksilo_widgets/
message_box.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! MessageBox — QMessageBox-style alert dialog.
5//!
6//! A higher-level surface built on top of [`ModalContainer`]
7//! for the classic "tell the user something and ask for a response"
8//! pattern: unsaved-changes prompts, error surfaces, confirmation
9//! dialogs, and informational notices. Mirrors QMessageBox (Qt),
10//! NSAlert (AppKit), and SwiftUI's `.alert(...)` while staying inside
11//! Teksilo's idioms — closure result handlers, `Signal`/`Prop`
12//! reactivity, `Intent`/`Action`/`Shortcut` routing for keyboard
13//! defaults, and AccessKit `Role::AlertDialog` accessibility.
14//!
15//! ## Quick tour
16//!
17//! ```ignore
18//! use teksilo::prelude::*;
19//! use teksilo::widgets::{MessageBox, MessageBoxButtons, StandardButton};
20//!
21//! fn on_close(ctx: &mut EventContext) {
22//!     MessageBox::question(lit!("Save changes?"))
23//!         .text(lit!("You have unsaved changes in report.skrib."))
24//!         .informative_text(lit!("Your changes will be lost if you don't save them."))
25//!         .buttons(MessageBoxButtons::SaveDiscardCancel)
26//!         .default_button(StandardButton::Save)
27//!         .escape_button(StandardButton::Cancel)
28//!         .on_result(|r, ctx| match r.button {
29//!             StandardButton::Save => save_and_close(ctx),
30//!             StandardButton::Discard => close(ctx),
31//!             _ => {}
32//!         })
33//!         .present(ctx);
34//! }
35//! # fn save_and_close(_: &mut EventContext) {}
36//! # fn close(_: &mut EventContext) {}
37//! ```
38//!
39//! ## Severity
40//!
41//! [`MessageBoxSeverity`] controls the icon drawn beside the title and
42//! its tint:
43//!
44//! - `Information` — info glyph, `status_info_fg` tint.
45//! - `Question` — question mark glyph, `accent` tint.
46//! - `Warning` — exclamation triangle, `status_warning_fg` tint.
47//! - `Critical` — X-mark circle, `status_error_fg` tint. Also disables
48//!   click-outside dismissal (Qt convention).
49//! - `None` — no icon, no tint.
50//!
51//! Severity is conveyed through the icon + title + text. Per Teksilo's
52//! Int UI baseline, buttons are **never** colored as "destructive":
53//! destructive intent lives in the dialog's severity and wording, not
54//! in the button. See [`crate::button`] for details.
55//!
56//! ## Default & escape buttons
57//!
58//! - `default_button` — activated by Enter (widget-scoped shortcut) and
59//!   receives initial focus on open (via `ModalRequest::focus_target`
60//!   plus `Widget::initial_focus_hint`). Styled with
61//!   `ButtonVariant::Filled`.
62//! - `escape_button` — activated by Escape. The fallback logic (for
63//!   presets with no explicit `escape_button`) picks: explicit
64//!   `escape_button` → first `Reject`-role button → `Cancel` → last
65//!   button.
66//!
67//! Each preset supplies a default: Ok for `Ok` and `OkCancel`, Save for
68//! `SaveDiscardCancel`, Retry for `RetryIgnoreAbort`, and **No** for
69//! `YesNo` and `YesNoCancel`.
70//!
71//! The Yes/No default is the negative answer on purpose. An Ok/Cancel box
72//! confirms something the user just asked for, so Ok is the answer they
73//! meant. A Yes/No box asks a question they did not initiate, and it is
74//! overwhelmingly asked before something irreversible — "Delete this?",
75//! "Discard your changes?". Defaulting to Yes means Enter destroys, and
76//! Enter is what a keyboard user presses on a dialog they have not
77//! finished reading. Where the question is safe, `default_button` puts Yes
78//! back in one reviewable line; the reverse default cannot be reviewed,
79//! because there is nothing on the screen to review.
80//!
81//! It matters more than it looks, because **no platform announces which
82//! button is the default**: `Node::keyboard_shortcut` appears in none of
83//! the three AccessKit adapters, so a screen-reader user discovers the
84//! default only by pressing Enter. Where focus lands is the whole contract.
85//!
86//! ## Title and button alignment
87//!
88//! [`MessageBox::title_alignment`] places the title in the header's text
89//! column (default `Leading`) and [`MessageBox::button_alignment`] places the
90//! button row (default `Trailing`), both as [`teksilo_tokens::HAlignment`].
91//! macOS alerts centre both; Windows and GNOME keep the title leading and the
92//! buttons trailing, which is the default. `Leading` and `Trailing` follow the
93//! layout direction.
94//!
95//! ## Result reporting
96//!
97//! [`MessageBox::on_result`] takes `impl Fn(MessageBoxResult,
98//! &mut EventContext) + 'static`. The callback fires exactly once, whichever
99//! route closes the dialog — button activation, Escape, a press outside, or a
100//! programmatic dismissal — with [`MessageBoxResult::dismissal`] naming which
101//! one. On a button activation the modal is then closed by the framework; on
102//! the other routes it is already going away.
103//!
104//! ## Accessibility
105//!
106//! The widget exposes `Role::AlertDialog`, with `set_modal()`,
107//! `set_live(Live::Assertive)`, `set_name(title)`, and
108//! `set_description(text + informative_text)`, so screen readers
109//! announce the dialog by its title as it opens and find the text as its
110//! description and by walking it. The `ModalContainer` that presents it
111//! publishes no dialog of its own around it, and the content under it is
112//! `Live::Off`, so the title is the one thing announced, once.
113
114use std::cell::{Cell, RefCell};
115use std::rc::Rc;
116
117use teksilo_canvas::{Rect, Size, SizeProposal};
118use teksilo_core::accessibility::AccessNodeBuilder;
119use teksilo_core::action::Action;
120use teksilo_core::build_context::BuildContext;
121use teksilo_core::event::{Key, Modifiers};
122use teksilo_core::modal::{ModalCloseBehavior, ModalPresentation, ModalRequest};
123use teksilo_core::shortcut::{KeyStroke, Shortcut};
124use teksilo_core::signal::Signal;
125use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
126use teksilo_core::widget_builder::WidgetBuilder;
127use teksilo_core::widget_id::WidgetId;
128use teksilo_i18n::LocalizedString;
129use teksilo_tokens::{Alignment, HAlignment, InputTokens, TargetRole, VAlignment};
130
131use crate::accordion::Accordion;
132use crate::button::{Button, ButtonVariant};
133use crate::checkbox::Checkbox;
134use crate::dialog::ModalContainer;
135use crate::primitives::{Expand, HStack, Spacer, TextWidget, VStack};
136use crate::scroll_area::ScrollArea;
137use crate::severity_badge::{SeverityBadge, SeverityIconKind};
138use teksilo_core::styles::density::dp;
139
140// ── Severity ────────────────────────────────────────────────────────
141
142/// Alert severity level. Drives the icon glyph + tint shown beside the
143/// title, and (for `Critical`) whether click-outside dismiss is enabled.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
145pub enum MessageBoxSeverity {
146    /// No icon. Use for plain notices where an icon would be noise.
147    #[default]
148    None,
149    /// Informational notice — blue circle with "i" glyph.
150    Information,
151    /// Confirmation prompt — accent-tinted circle with "?" glyph.
152    Question,
153    /// Non-fatal warning — amber triangle with "!" glyph.
154    Warning,
155    /// Critical error — red circle with an "X" glyph. Click-outside
156    /// dismissal is disabled (Escape still works).
157    Critical,
158}
159
160// ── Standard button catalog ─────────────────────────────────────────
161
162/// Semantic role of a message-box button. Used for fallback escape
163/// resolution (`Reject` wins when no explicit escape button is set).
164/// Teksilo deliberately does **not** render `Destructive` buttons with
165/// a red fill — the dialog's severity icon and wording carry that
166/// signal. See [`crate::button`] for the framework-level rationale.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum ButtonRole {
169    /// Confirms / proceeds. Ok, Yes, Save, Open, Apply, Retry.
170    Accept,
171    /// Bails out. Cancel, Close, No, Abort.
172    Reject,
173    /// Data-loss action. Discard. (Same visuals as Regular — the
174    /// severity of the surrounding MessageBox carries the warning.)
175    Destructive,
176    /// Side action. Help, Reset, RestoreDefaults, Ignore, and the
177    /// "to all" variants.
178    Action,
179}
180
181/// The Qt-modeled catalog of standard buttons. Each variant resolves
182/// to a localized label, a semantic [`ButtonRole`], and a stable
183/// intent-name string used internally for shortcut/action routing.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
185pub enum StandardButton {
186    /// Accept / confirm. `ButtonRole::Accept`.
187    Ok,
188    /// Cancel the operation. `ButtonRole::Reject`.
189    Cancel,
190    /// Close the dialog. `ButtonRole::Reject`.
191    Close,
192    /// Confirm with "Yes". `ButtonRole::Accept`.
193    Yes,
194    /// Decline with "No". `ButtonRole::Reject`.
195    No,
196    /// Confirm all remaining items. `ButtonRole::Accept`.
197    YesToAll,
198    /// Decline all remaining items. `ButtonRole::Reject`.
199    NoToAll,
200    /// Save changes. `ButtonRole::Accept`.
201    Save,
202    /// Save all open items. `ButtonRole::Accept`.
203    SaveAll,
204    /// Discard changes without saving. `ButtonRole::Destructive`.
205    Discard,
206    /// Apply changes without closing. `ButtonRole::Accept`.
207    Apply,
208    /// Reset to defaults. `ButtonRole::Action`.
209    Reset,
210    /// Restore factory defaults. `ButtonRole::Action`.
211    RestoreDefaults,
212    /// Abort the current operation. `ButtonRole::Reject`.
213    Abort,
214    /// Retry the failed operation. `ButtonRole::Accept`.
215    Retry,
216    /// Ignore the error and continue. `ButtonRole::Action`.
217    Ignore,
218    /// Open a file or resource. `ButtonRole::Accept`.
219    Open,
220    /// Show help. `ButtonRole::Action`.
221    Help,
222}
223
224impl StandardButton {
225    /// The button's semantic role — used internally by MessageBox's
226    /// escape-button fallback resolution, and available to callers that
227    /// want to inspect a `MessageBoxButton`'s role.
228    pub fn role(self) -> ButtonRole {
229        match self {
230            Self::Ok
231            | Self::Yes
232            | Self::YesToAll
233            | Self::Save
234            | Self::SaveAll
235            | Self::Apply
236            | Self::Retry
237            | Self::Open => ButtonRole::Accept,
238            Self::Cancel | Self::Close | Self::No | Self::NoToAll | Self::Abort => {
239                ButtonRole::Reject
240            }
241            Self::Discard => ButtonRole::Destructive,
242            Self::Reset | Self::RestoreDefaults | Self::Ignore | Self::Help => ButtonRole::Action,
243        }
244    }
245
246    /// Stable string id used as both the shortcut id and the intent
247    /// name for routing default/escape key activations. Scoped to a
248    /// MessageBox instance via widget-scoped shortcut registration, so
249    /// the same id is safe to reuse across instances.
250    pub fn intent_name(self) -> &'static str {
251        match self {
252            Self::Ok => "messagebox.btn.ok",
253            Self::Cancel => "messagebox.btn.cancel",
254            Self::Close => "messagebox.btn.close",
255            Self::Yes => "messagebox.btn.yes",
256            Self::No => "messagebox.btn.no",
257            Self::YesToAll => "messagebox.btn.yes_to_all",
258            Self::NoToAll => "messagebox.btn.no_to_all",
259            Self::Save => "messagebox.btn.save",
260            Self::SaveAll => "messagebox.btn.save_all",
261            Self::Discard => "messagebox.btn.discard",
262            Self::Apply => "messagebox.btn.apply",
263            Self::Reset => "messagebox.btn.reset",
264            Self::RestoreDefaults => "messagebox.btn.restore_defaults",
265            Self::Abort => "messagebox.btn.abort",
266            Self::Retry => "messagebox.btn.retry",
267            Self::Ignore => "messagebox.btn.ignore",
268            Self::Open => "messagebox.btn.open",
269            Self::Help => "messagebox.btn.help",
270        }
271    }
272
273    /// Default label for the button. Resolved through the Fluent
274    /// catalog via `tr_widget!` so apps can override per-locale.
275    pub fn default_label(self) -> LocalizedString {
276        match self {
277            Self::Ok => teksilo_i18n::tr_widget!(messagebox_btn_ok()),
278            Self::Cancel => teksilo_i18n::tr_widget!(messagebox_btn_cancel()),
279            Self::Close => teksilo_i18n::tr_widget!(messagebox_btn_close()),
280            Self::Yes => teksilo_i18n::tr_widget!(messagebox_btn_yes()),
281            Self::No => teksilo_i18n::tr_widget!(messagebox_btn_no()),
282            Self::YesToAll => teksilo_i18n::tr_widget!(messagebox_btn_yes_to_all()),
283            Self::NoToAll => teksilo_i18n::tr_widget!(messagebox_btn_no_to_all()),
284            Self::Save => teksilo_i18n::tr_widget!(messagebox_btn_save()),
285            Self::SaveAll => teksilo_i18n::tr_widget!(messagebox_btn_save_all()),
286            Self::Discard => teksilo_i18n::tr_widget!(messagebox_btn_discard()),
287            Self::Apply => teksilo_i18n::tr_widget!(messagebox_btn_apply()),
288            Self::Reset => teksilo_i18n::tr_widget!(messagebox_btn_reset()),
289            Self::RestoreDefaults => teksilo_i18n::tr_widget!(messagebox_btn_restore_defaults()),
290            Self::Abort => teksilo_i18n::tr_widget!(messagebox_btn_abort()),
291            Self::Retry => teksilo_i18n::tr_widget!(messagebox_btn_retry()),
292            Self::Ignore => teksilo_i18n::tr_widget!(messagebox_btn_ignore()),
293            Self::Open => teksilo_i18n::tr_widget!(messagebox_btn_open()),
294            Self::Help => teksilo_i18n::tr_widget!(messagebox_btn_help()),
295        }
296    }
297}
298
299/// A single button placement inside a MessageBox, including an optional
300/// per-instance label override. Callers usually build these via
301/// [`From<StandardButton>`] (`StandardButton::Ok.into()`), or
302/// construct them manually when `Custom` is needed.
303#[derive(Debug, Clone)]
304pub struct MessageBoxButton {
305    /// Which standard button this is — drives role, intent name, and
306    /// (when `label_override` is `None`) the label.
307    pub kind: StandardButton,
308    /// Optional explicit label that overrides `kind.default_label()`.
309    /// Use sparingly: prefer translating the default via Fluent rather
310    /// than hard-coding per-call labels.
311    pub label_override: Option<LocalizedString>,
312}
313
314impl MessageBoxButton {
315    /// Build a button from a `StandardButton` with the default label.
316    pub fn standard(kind: StandardButton) -> Self {
317        Self {
318            kind,
319            label_override: None,
320        }
321    }
322
323    /// Override the default translated label.
324    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
325        self.label_override = Some(label.into());
326        self
327    }
328
329    fn resolved_label(&self) -> LocalizedString {
330        self.label_override
331            .clone()
332            .unwrap_or_else(|| self.kind.default_label())
333    }
334}
335
336impl From<StandardButton> for MessageBoxButton {
337    fn from(kind: StandardButton) -> Self {
338        Self::standard(kind)
339    }
340}
341
342/// Pre-built button bundles covering the common MessageBox shapes.
343/// Custom combinations go through [`MessageBox::add_button`] or
344/// [`MessageBoxButtons::Custom`].
345#[derive(Debug, Clone)]
346pub enum MessageBoxButtons {
347    /// Just Ok.
348    Ok,
349    /// Ok + Cancel, Ok default, Cancel escape.
350    OkCancel,
351    /// Yes + No. **No** is the default, and No is the escape. See the module
352    /// docs for why the default is the negative answer.
353    YesNo,
354    /// Yes + No + Cancel. **No** is the default, Cancel is the escape — Enter
355    /// takes the safe answer to the question asked, Escape leaves the dialog.
356    YesNoCancel,
357    /// The unsaved-changes triad: Save + Discard + Cancel.
358    SaveDiscardCancel,
359    /// The error-recovery triad: Retry + Ignore + Abort.
360    RetryIgnoreAbort,
361    /// Explicit list. MessageBox preserves the order as the visual
362    /// button order (leading Spacer pushes all buttons to the trailing
363    /// edge; default button may appear anywhere).
364    Custom(Vec<MessageBoxButton>),
365}
366
367impl MessageBoxButtons {
368    fn into_buttons(self) -> Vec<MessageBoxButton> {
369        match self {
370            Self::Ok => vec![StandardButton::Ok.into()],
371            Self::OkCancel => vec![StandardButton::Cancel.into(), StandardButton::Ok.into()],
372            Self::YesNo => vec![StandardButton::No.into(), StandardButton::Yes.into()],
373            Self::YesNoCancel => vec![
374                StandardButton::Cancel.into(),
375                StandardButton::No.into(),
376                StandardButton::Yes.into(),
377            ],
378            Self::SaveDiscardCancel => vec![
379                StandardButton::Discard.into(),
380                StandardButton::Cancel.into(),
381                StandardButton::Save.into(),
382            ],
383            Self::RetryIgnoreAbort => vec![
384                StandardButton::Abort.into(),
385                StandardButton::Ignore.into(),
386                StandardButton::Retry.into(),
387            ],
388            Self::Custom(items) => items,
389        }
390    }
391
392    /// Default button hint derived from the preset. Callers that want
393    /// a different default override via `MessageBox::default_button`.
394    ///
395    /// `Ok` and `OkCancel` default to Ok because an Ok/Cancel box asks the user
396    /// to confirm something they just asked for. `YesNo` and `YesNoCancel`
397    /// default to **No**, because a Yes/No box asks a question the user did not
398    /// initiate, and the answer that cannot be undone is Yes. See the variant
399    /// docs.
400    fn preset_default(&self) -> Option<StandardButton> {
401        match self {
402            Self::Ok => Some(StandardButton::Ok),
403            Self::OkCancel => Some(StandardButton::Ok),
404            Self::YesNo => Some(StandardButton::No),
405            Self::YesNoCancel => Some(StandardButton::No),
406            Self::SaveDiscardCancel => Some(StandardButton::Save),
407            Self::RetryIgnoreAbort => Some(StandardButton::Retry),
408            Self::Custom(_) => None,
409        }
410    }
411
412    /// Escape button hint derived from the preset.
413    fn preset_escape(&self) -> Option<StandardButton> {
414        match self {
415            Self::Ok => Some(StandardButton::Ok),
416            Self::OkCancel => Some(StandardButton::Cancel),
417            Self::YesNo => Some(StandardButton::No),
418            Self::YesNoCancel => Some(StandardButton::Cancel),
419            Self::SaveDiscardCancel => Some(StandardButton::Cancel),
420            Self::RetryIgnoreAbort => Some(StandardButton::Abort),
421            Self::Custom(_) => None,
422        }
423    }
424}
425
426// ── Result ──────────────────────────────────────────────────────────
427
428/// How a [`MessageBox`] came to close.
429///
430/// This replaced a `dismissed_by_escape: bool` whose own rustdoc claimed to
431/// cover scrim-click as well — a contract no code path could produce, because
432/// the only writer was the Escape action. One field that names the route
433/// cannot drift from its documentation the way two overlapping booleans can,
434/// and the caller can finally tell "the user chose Cancel" from "the user
435/// waved the dialog away", which for a Save/Discard/Cancel prompt are
436/// different answers.
437#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
438#[non_exhaustive]
439pub enum MessageBoxDismissal {
440    /// A button was chosen: clicked, or Enter on the default.
441    Button,
442    /// Escape, resolved to the escape button.
443    Escape,
444    /// A press outside the dialog, where its `ModalCloseBehavior` permits it.
445    ClickOutside,
446    /// It went away for another reason — the application dismissed it, or an
447    /// enclosing surface closed and took it along. `button` still carries the
448    /// escape-button resolution, because something has to be reported and
449    /// that is the same answer Escape would have given.
450    Programmatic,
451}
452
453/// Report passed to [`MessageBox::on_result`] when the dialog closes.
454#[derive(Debug, Clone, Copy)]
455pub struct MessageBoxResult {
456    /// Which button fired — either by click, Enter (the focused button,
457    /// else the default), or escape-button resolution for any of the
458    /// dismissal routes.
459    pub button: StandardButton,
460    /// State of the "Don't show again" checkbox at dismiss time, when
461    /// one was configured via [`MessageBox::show_again_checkbox`] or
462    /// [`MessageBox::show_again_checkbox_state`]. `false` when no
463    /// checkbox was attached.
464    pub checkbox_checked: bool,
465    /// Which route closed the dialog.
466    pub dismissal: MessageBoxDismissal,
467}
468
469impl From<teksilo_core::overlay::DismissReason> for MessageBoxDismissal {
470    fn from(reason: teksilo_core::overlay::DismissReason) -> Self {
471        use teksilo_core::overlay::DismissReason as R;
472        match reason {
473            R::Escape => Self::Escape,
474            R::OutsidePress => Self::ClickOutside,
475            // A modal has no hover-out route, and the remaining reasons all
476            // mean "something other than the user's hand closed this".
477            _ => Self::Programmatic,
478        }
479    }
480}
481
482impl MessageBoxResult {
483    /// Whether the dialog went away without the user choosing a button.
484    ///
485    /// The predicate the old `dismissed_by_escape` flag was reached for, minus
486    /// the claim that Escape was the only way to get there.
487    pub fn was_dismissed(&self) -> bool {
488        self.dismissal != MessageBoxDismissal::Button
489    }
490}
491
492const SEVERITY_ICON_SIZE: f32 = 48.0;
493
494/// [`SEVERITY_ICON_SIZE`] raised to the density's `target_size`
495/// (24 / 32 / 44 dp). The identity at Compact.
496fn severity_icon_size(tokens: &InputTokens) -> f32 {
497    dp(SEVERITY_ICON_SIZE, TargetRole::Target, tokens)
498}
499
500/// How tall the expanded "Show details" pane is allowed to grow before it scrolls.
501///
502/// `detailed_text` is where callers put the things that have no length bound — a stack
503/// trace, an OS error dump, a list of every affected row — so without a cap the accordion
504/// simply grew the dialog until it ran off the bottom of the screen, taking the buttons
505/// with it. Tall enough for a dozen lines of `small` text, short enough that the dialog
506/// still fits a laptop display beneath a header and a button row.
507const DETAILS_MAX_HEIGHT: f32 = 220.0;
508
509const DEFAULT_INTENT_NAME: &str = "messagebox.accept_default";
510const ESCAPE_INTENT_NAME: &str = "messagebox.escape";
511
512/// Map a `MessageBoxSeverity` onto the shared severity-badge kind.
513/// `None` has no badge (the dialog reads as a plain notice).
514fn severity_icon_kind(severity: MessageBoxSeverity) -> Option<SeverityIconKind> {
515    match severity {
516        MessageBoxSeverity::None => None,
517        MessageBoxSeverity::Information => Some(SeverityIconKind::Info),
518        MessageBoxSeverity::Question => Some(SeverityIconKind::Question),
519        MessageBoxSeverity::Warning => Some(SeverityIconKind::Warning),
520        MessageBoxSeverity::Critical => Some(SeverityIconKind::Error),
521    }
522}
523
524// ── Internal runtime state ─────────────────────────────────────────
525
526/// State shared between the MessageBox widget, its footer buttons, and
527/// the Enter/Escape shortcut actions. Lives in an `Rc` so all three
528/// dispatch paths can read/write the same checkbox state and fire the
529/// same result callback exactly once per session.
530struct State {
531    on_result: RefCell<Option<Box<dyn Fn(MessageBoxResult, &mut EventContext)>>>,
532    checkbox: Signal<bool>,
533    escape_button: Cell<Option<StandardButton>>,
534    default_button: Cell<Option<StandardButton>>,
535    /// Fallback list of buttons in the order they were configured —
536    /// consulted when neither `escape_button` nor a `Reject`-role
537    /// button is set.
538    buttons: RefCell<Vec<StandardButton>>,
539    /// Every button this box built, by widget id. Read by the Enter
540    /// shortcut so it can answer for the button the user is standing on
541    /// rather than for the default. Rebuilt on every `build`.
542    button_ids: RefCell<Vec<(WidgetId, StandardButton)>>,
543    /// Guards against multiple result-callback invocations when a
544    /// button click races the Escape shortcut.
545    fired: Cell<bool>,
546}
547
548impl State {
549    fn new(checkbox: Signal<bool>) -> Rc<Self> {
550        Rc::new(Self {
551            on_result: RefCell::new(None),
552            checkbox,
553            escape_button: Cell::new(None),
554            default_button: Cell::new(None),
555            buttons: RefCell::new(Vec::new()),
556            button_ids: RefCell::new(Vec::new()),
557            fired: Cell::new(false),
558        })
559    }
560
561    /// Which of this box's buttons a widget id is, if it is one of them.
562    fn button_for(&self, id: WidgetId) -> Option<StandardButton> {
563        self.button_ids
564            .borrow()
565            .iter()
566            .find(|(btn, _)| *btn == id)
567            .map(|(_, kind)| *kind)
568    }
569
570    /// Report the answer and close the dialog. The path a button takes.
571    fn fire(&self, button: StandardButton, dismissal: MessageBoxDismissal, ctx: &mut EventContext) {
572        if self.report(button, dismissal, ctx) {
573            ctx.dismiss_modal();
574        }
575    }
576
577    /// Report the answer for a dialog that is **already being dismissed**, and
578    /// return whether this call is the one that reported.
579    ///
580    /// Split from [`fire`](Self::fire) for the `on_dismiss` path: the overlay
581    /// is mid-teardown there, and `ctx.dismiss_modal()` only sets a deferred
582    /// flag — asking again would spend it on whatever modal is topmost when it
583    /// is read, which for a dialog opened over another dialog means closing
584    /// the wrong one.
585    fn report(
586        &self,
587        button: StandardButton,
588        dismissal: MessageBoxDismissal,
589        ctx: &mut EventContext,
590    ) -> bool {
591        if self.fired.replace(true) {
592            return false;
593        }
594        let result = MessageBoxResult {
595            button,
596            checkbox_checked: self.checkbox.get(),
597            dismissal,
598        };
599        if let Some(handler) = self.on_result.borrow().as_ref() {
600            handler(result, ctx);
601        }
602        true
603    }
604
605    fn resolve_escape_button(&self) -> Option<StandardButton> {
606        if let Some(btn) = self.escape_button.get() {
607            return Some(btn);
608        }
609        let buttons = self.buttons.borrow();
610        if let Some(btn) = buttons.iter().find(|b| b.role() == ButtonRole::Reject) {
611            return Some(*btn);
612        }
613        if buttons.contains(&StandardButton::Cancel) {
614            return Some(StandardButton::Cancel);
615        }
616        buttons.last().copied()
617    }
618}
619
620// ── The MessageBox widget ──────────────────────────────────────────
621
622/// A modal alert dialog that displays a severity icon, title, body text, and
623/// one or more buttons.
624///
625/// Constructed via severity-named constructors ([`MessageBox::information`],
626/// [`MessageBox::warning`], [`MessageBox::critical`], [`MessageBox::question`],
627/// [`MessageBox::plain`]), configured fluently, and presented with
628/// [`MessageBox::present`]. See the module documentation for the full guide.
629pub struct MessageBox {
630    severity: MessageBoxSeverity,
631    title: LocalizedString,
632    text: Option<LocalizedString>,
633    informative_text: Option<LocalizedString>,
634    detailed_text: Option<LocalizedString>,
635    buttons_config: Option<MessageBoxButtons>,
636    extra_buttons: Vec<MessageBoxButton>,
637    default_button: Option<StandardButton>,
638    escape_button: Option<StandardButton>,
639    show_again_label: Option<LocalizedString>,
640    show_again_state: Option<Signal<bool>>,
641    title_alignment: HAlignment,
642    button_alignment: HAlignment,
643    on_result: Option<Box<dyn Fn(MessageBoxResult, &mut EventContext)>>,
644    default_button_id: Cell<Option<WidgetId>>,
645    root_child_id: Option<WidgetId>,
646    state: Option<Rc<State>>,
647    /// Published by `build` so [`present`](Self::present)'s `on_dismiss` can
648    /// reach the state. `present` moves `self` into a deferred builder that
649    /// does not run until the modal is mounted, so the closure has to be
650    /// handed a slot rather than the `State` itself.
651    state_slot: Option<Rc<RefCell<Option<Rc<State>>>>>,
652}
653
654impl std::fmt::Debug for MessageBox {
655    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
656        f.debug_struct("MessageBox")
657            .field("severity", &self.severity)
658            .field("title", &self.title)
659            .field("text", &self.text)
660            .field("informative_text", &self.informative_text)
661            .field("detailed_text", &self.detailed_text)
662            .field("default_button", &self.default_button)
663            .field("escape_button", &self.escape_button)
664            .finish()
665    }
666}
667
668impl MessageBox {
669    fn new_with_severity(severity: MessageBoxSeverity, title: impl Into<LocalizedString>) -> Self {
670        let title = title.into();
671        Self {
672            severity,
673            title,
674            text: None,
675            informative_text: None,
676            detailed_text: None,
677            buttons_config: None,
678            extra_buttons: Vec::new(),
679            default_button: None,
680            escape_button: None,
681            show_again_label: None,
682            show_again_state: None,
683            title_alignment: HAlignment::Leading,
684            button_alignment: HAlignment::Trailing,
685            on_result: None,
686            default_button_id: Cell::new(None),
687            root_child_id: None,
688            state: None,
689            state_slot: None,
690        }
691    }
692
693    /// Construct an informational MessageBox (`Information` severity).
694    pub fn information(title: impl Into<LocalizedString>) -> Self {
695        Self::new_with_severity(MessageBoxSeverity::Information, title)
696    }
697
698    /// Construct a warning MessageBox (`Warning` severity).
699    pub fn warning(title: impl Into<LocalizedString>) -> Self {
700        Self::new_with_severity(MessageBoxSeverity::Warning, title)
701    }
702
703    /// Construct a critical-error MessageBox (`Critical` severity).
704    /// Click-outside dismissal is disabled; use an explicit button or
705    /// Escape to close.
706    pub fn critical(title: impl Into<LocalizedString>) -> Self {
707        Self::new_with_severity(MessageBoxSeverity::Critical, title)
708    }
709
710    /// Construct a confirmation / question MessageBox (`Question`
711    /// severity).
712    pub fn question(title: impl Into<LocalizedString>) -> Self {
713        Self::new_with_severity(MessageBoxSeverity::Question, title)
714    }
715
716    /// Construct a plain MessageBox with no severity icon.
717    pub fn plain(title: impl Into<LocalizedString>) -> Self {
718        Self::new_with_severity(MessageBoxSeverity::None, title)
719    }
720
721    /// Primary message line, rendered in `typography.body` with
722    /// `text_primary`. Prefer a short, self-contained sentence —
723    /// details belong in `informative_text`.
724    pub fn text(mut self, text: impl Into<LocalizedString>) -> Self {
725        self.text = Some(text.into());
726        self
727    }
728
729    /// Secondary, explanatory text rendered below the primary text in
730    /// `typography.body` with `text_secondary`. Matches Qt's
731    /// `setInformativeText`.
732    pub fn informative_text(mut self, text: impl Into<LocalizedString>) -> Self {
733        self.informative_text = Some(text.into());
734        self
735    }
736
737    /// Detailed text hidden behind a "Show details" [`Accordion`] —
738    /// for technical diagnostics (stack traces, error codes). Matches
739    /// Qt's `setDetailedText`.
740    pub fn detailed_text(mut self, text: impl Into<LocalizedString>) -> Self {
741        self.detailed_text = Some(text.into());
742        self
743    }
744
745    /// Apply a preset button bundle. Implicitly sets default and
746    /// escape buttons for the preset (both can be overridden via
747    /// [`MessageBox::default_button`] and
748    /// [`MessageBox::escape_button`]).
749    pub fn buttons(mut self, preset: MessageBoxButtons) -> Self {
750        if self.default_button.is_none() {
751            self.default_button = preset.preset_default();
752        }
753        if self.escape_button.is_none() {
754            self.escape_button = preset.preset_escape();
755        }
756        self.buttons_config = Some(preset);
757        self
758    }
759
760    /// Append a single button. Use to augment a preset (rare) or to
761    /// build a bespoke button row without going through
762    /// [`MessageBoxButtons::Custom`].
763    pub fn add_button(mut self, button: impl Into<MessageBoxButton>) -> Self {
764        self.extra_buttons.push(button.into());
765        self
766    }
767
768    /// Append several buttons from an iterator, in order.
769    ///
770    /// The loop form of [`add_button`](Self::add_button), for a bespoke button
771    /// row built from data rather than spelled out one call at a time.
772    pub fn add_buttons(
773        self,
774        buttons: impl IntoIterator<Item = impl Into<MessageBoxButton>>,
775    ) -> Self {
776        buttons.into_iter().fold(self, Self::add_button)
777    }
778
779    /// Mark which button activates on Enter and receives initial
780    /// focus. Must refer to one of the buttons configured via
781    /// `buttons` / `add_button`.
782    pub fn default_button(mut self, which: StandardButton) -> Self {
783        self.default_button = Some(which);
784        self
785    }
786
787    /// Mark which button activates on Escape (and scrim-click, when
788    /// allowed). Must refer to one of the configured buttons.
789    pub fn escape_button(mut self, which: StandardButton) -> Self {
790        self.escape_button = Some(which);
791        self
792    }
793
794    /// Attach a "Don't show again"-style checkbox below the body.
795    /// Internally creates a `Signal<bool>` initialized to `false` and
796    /// reports its state in [`MessageBoxResult::checkbox_checked`].
797    /// For external observation, use
798    /// [`MessageBox::show_again_checkbox_state`] instead.
799    pub fn show_again_checkbox(mut self, label: impl Into<LocalizedString>) -> Self {
800        self.show_again_label = Some(label.into());
801        self
802    }
803
804    /// Like [`MessageBox::show_again_checkbox`], but with a
805    /// caller-owned `Signal<bool>` so the checkbox state survives the
806    /// dialog lifetime (useful for "remember my choice" persistence).
807    pub fn show_again_checkbox_state(mut self, signal: Signal<bool>) -> Self {
808        self.show_again_state = Some(signal);
809        self
810    }
811
812    /// Where the title sits in the header's text column. Default
813    /// [`HAlignment::Leading`]. `Center` matches a macOS alert; Windows and
814    /// GNOME keep the title leading. `Leading` / `Trailing` follow the layout
815    /// direction, so they mirror in right-to-left locales.
816    pub fn title_alignment(mut self, alignment: HAlignment) -> Self {
817        self.title_alignment = alignment;
818        self
819    }
820
821    /// Where the button row sits along the bottom of the dialog. Default
822    /// [`HAlignment::Trailing`]. `Center` matches a macOS alert; `Leading`
823    /// packs the buttons at the start edge. `Leading` / `Trailing` follow the
824    /// layout direction, so they mirror in right-to-left locales.
825    pub fn button_alignment(mut self, alignment: HAlignment) -> Self {
826        self.button_alignment = alignment;
827        self
828    }
829
830    /// Register the result callback, invoked exactly once however the
831    /// dialog closes — a button (by click, or by the Enter/Escape
832    /// shortcut), a press outside, or a programmatic dismissal.
833    pub fn on_result(mut self, f: impl Fn(MessageBoxResult, &mut EventContext) + 'static) -> Self {
834        self.on_result = Some(Box::new(f));
835        self
836    }
837
838    /// Present the MessageBox as a modal on top of `ctx`'s current
839    /// tree. Consumes `self`; callers who need to present multiple
840    /// dialogs with shared config should build a factory closure.
841    pub fn present(mut self, ctx: &mut EventContext) {
842        let title = self.title.clone();
843        let close_behavior = if self.severity == MessageBoxSeverity::Critical {
844            ModalCloseBehavior::EscapeKey
845        } else {
846            ModalCloseBehavior::EscapeOrClickOutside
847        };
848
849        // Report the answer however the dialog goes away, not only when a
850        // button is pressed. Escape and a press outside both close the modal
851        // through the overlay system, which never reached the button
852        // resolution below — so a dismissed dialog told its caller nothing,
853        // and a "Save changes?" prompt lost the user's answer outright. The
854        // `fired` latch makes this a no-op when a button already answered.
855        let slot: Rc<RefCell<Option<Rc<State>>>> = Rc::new(RefCell::new(None));
856        self.state_slot = Some(slot.clone());
857        let on_dismiss: teksilo_core::overlay::OverlayDismissCallback =
858            Rc::new(move |reason, ctx: &mut EventContext| {
859                let Some(state) = slot.borrow().clone() else {
860                    return;
861                };
862                let Some(button) = state.resolve_escape_button() else {
863                    return;
864                };
865                // `report`, not `fire`: the overlay is already being torn
866                // down, and `dismiss_modal` here would be spent on whatever
867                // modal is topmost when the deferred flag is read.
868                state.report(button, MessageBoxDismissal::from(reason), ctx);
869            });
870
871        let dialog_title = self.title.clone();
872        let mut inner = Some(self);
873        ctx.present_modal(
874            ModalRequest::deferred(move |tree| {
875                let mb = inner
876                    .take()
877                    .expect("MessageBox present closure called twice");
878                tree.add(ModalContainer::new(mb).title(dialog_title.clone()))
879            })
880            .presentation(ModalPresentation::Auto)
881            .close_behavior(close_behavior)
882            .title(title)
883            .size(460, 140)
884            .on_dismiss(on_dismiss),
885        );
886    }
887
888    fn resolve_buttons(&mut self) -> Vec<MessageBoxButton> {
889        let mut resolved = self
890            .buttons_config
891            .clone()
892            .map(|b| b.into_buttons())
893            .unwrap_or_default();
894        resolved.extend(self.extra_buttons.iter().cloned());
895        if resolved.is_empty() {
896            resolved.push(StandardButton::Ok.into());
897            if self.default_button.is_none() {
898                self.default_button = Some(StandardButton::Ok);
899            }
900            if self.escape_button.is_none() {
901                self.escape_button = Some(StandardButton::Ok);
902            }
903        }
904        resolved
905    }
906}
907
908impl Widget for MessageBox {
909    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
910        let theme = ctx.theme().clone();
911
912        let checkbox_signal = self
913            .show_again_state
914            .clone()
915            .unwrap_or_else(|| ctx.signal(false));
916        let state = State::new(checkbox_signal.clone());
917        *state.on_result.borrow_mut() = self.on_result.take();
918
919        let buttons = self.resolve_buttons();
920        *state.buttons.borrow_mut() = buttons.iter().map(|b| b.kind).collect();
921        state.default_button.set(self.default_button);
922        state.escape_button.set(self.escape_button);
923
924        let mut header_text_stack = VStack::new().spacing(6.0);
925        // The MessageBox itself already carries the title as its
926        // AlertDialog name (see `Widget::accessibility` below).
927        let title_text = TextWidget::new(self.title.clone())
928            .style(theme.typography.body_bold.clone())
929            .color(theme.colors.text_primary)
930            .a11y_hidden();
931        header_text_stack =
932            if self.title_alignment == HAlignment::Leading {
933                header_text_stack.child(title_text)
934            } else {
935                // Claim the column's width and place the title inside it; the
936                // default (leading) keeps the bare title and the same tree as
937                // before the knob existed.
938                header_text_stack.child(Expand::horizontal().child(title_text).align_child(
939                    Alignment {
940                        horizontal: self.title_alignment,
941                        vertical: VAlignment::Top,
942                    },
943                ))
944            };
945        if let Some(text) = self.text.clone() {
946            header_text_stack = header_text_stack.child(
947                TextWidget::new(text)
948                    .style(theme.typography.body.clone())
949                    .color(theme.colors.text_primary),
950            );
951        }
952        if let Some(info) = self.informative_text.clone() {
953            header_text_stack = header_text_stack.child(
954                TextWidget::new(info)
955                    .style(theme.typography.body.clone())
956                    .color(theme.colors.text_secondary),
957            );
958        }
959
960        let header: Box<dyn Widget> = if let Some(kind) = severity_icon_kind(self.severity) {
961            // Wrap the text stack in `Expand::horizontal()` so HStack
962            // distributes its width slack to the text column. Without
963            // this the HStack measures the text stack with width=None
964            // (single-line), which makes the body / informative text
965            // overflow the dialog instead of wrapping.
966            Box::new(
967                HStack::new()
968                    .spacing(16.0)
969                    .alignment(VAlignment::Top)
970                    .child(SeverityBadge::new(
971                        kind,
972                        severity_icon_size(&ctx.theme().input),
973                    ))
974                    .child(Expand::horizontal().child(header_text_stack)),
975            )
976        } else {
977            Box::new(header_text_stack)
978        };
979
980        let detailed_child: Option<Box<dyn Widget>> = self.detailed_text.clone().map(|text| {
981            let expanded = ctx.signal(false);
982            let label: LocalizedString = teksilo_i18n::tr_widget!(messagebox_show_details());
983            let body = TextWidget::new(text)
984                .style(theme.typography.small.clone())
985                .color(theme.colors.text_secondary);
986            // Capped and scrollable rather than free-growing: see `DETAILS_MAX_HEIGHT`.
987            // `preferred_height` caps the height only when the parent proposes an
988            // unconstrained one — which a hugging dialog does — while leaving the width to
989            // follow the content, so short details still size to themselves and never
990            // acquire a scroll bar they do not need.
991            let scroller = ScrollArea::new()
992                .child(body)
993                .preferred_height(DETAILS_MAX_HEIGHT);
994            let accordion: Box<dyn Widget> =
995                Box::new(Accordion::new(label, expanded).content(scroller));
996            accordion
997        });
998
999        let checkbox_child: Option<Box<dyn Widget>> = self.show_again_label.clone().map(|label| {
1000            let cb: Box<dyn Widget> = Box::new(Checkbox::new(checkbox_signal.clone()).label(label));
1001            cb
1002        });
1003
1004        // Slack goes before the buttons when they sit trailing or centred,
1005        // and after them when they sit leading or centred.
1006        let button_alignment = self.button_alignment;
1007        let mut footer = HStack::new().spacing(8.0);
1008        if button_alignment != HAlignment::Leading {
1009            footer = footer.child(Spacer::new());
1010        }
1011        state.button_ids.borrow_mut().clear();
1012        for button_cfg in &buttons {
1013            let kind = button_cfg.kind;
1014            let label = button_cfg.resolved_label();
1015            let variant = if Some(kind) == self.default_button {
1016                ButtonVariant::Filled
1017            } else {
1018                ButtonVariant::Plain
1019            };
1020            let state_for_btn = state.clone();
1021            let btn_id = ctx.add(
1022                Button::new(label)
1023                    .variant(variant)
1024                    .on_activate_fn(move |ctx| {
1025                        state_for_btn.fire(kind, MessageBoxDismissal::Button, ctx);
1026                    }),
1027            );
1028            if Some(kind) == self.default_button {
1029                self.default_button_id.set(Some(btn_id));
1030            }
1031            state.button_ids.borrow_mut().push((btn_id, kind));
1032            footer = footer.child(btn_id);
1033        }
1034        if button_alignment != HAlignment::Trailing {
1035            footer = footer.child(Spacer::new());
1036        }
1037
1038        let mut stack = VStack::new().spacing(16.0);
1039        stack = stack.child(ctx.add_boxed(header));
1040        if let Some(det) = detailed_child {
1041            stack = stack.child(ctx.add_boxed(det));
1042        }
1043        if let Some(cb) = checkbox_child {
1044            stack = stack.child(ctx.add_boxed(cb));
1045        }
1046        // Push the footer to the bottom of the dialog by absorbing any
1047        // vertical slack with a Spacer (flex=1).
1048        stack = stack.child(ctx.add(Spacer::new()));
1049        let footer_id = ctx.add(footer);
1050        stack = stack.child(footer_id);
1051
1052        // `Live::Off` on the root of the content. The box is an assertive
1053        // live region so that its *name*, the title, interrupts as it
1054        // opens, and `accesskit_consumer` hands a node's politeness down to
1055        // every descendant that sets none (node.rs:906-910). All three
1056        // adapters announce each named node entering the filtered tree with
1057        // an inherited politeness (atspi_common adapter.rs:71-77, windows
1058        // adapter.rs:255-263, macos event.rs:236-241), so once the dialog
1059        // panel stopped hiding the box, opening it announced the text, the
1060        // informative text and every button as well, each assertive, in
1061        // hash order, and each cutting off the one before: Orca 46.1 speaks
1062        // an announcement by interrupting the last (`speakMessage` in
1063        // `scripts/default.py` defaults to `interrupt=True`), so what was left
1064        // to hear was a button's name. The text still reaches a reader as the
1065        // box's description and by walking it. The root stays a
1066        // `GenericContainer`: the walker keeps it because it now says
1067        // something, and the adapters step through it to its children, which
1068        // inherit what it says.
1069        let root = ctx.add(
1070            stack
1071                .access_role(teksilo_core::accesskit::Role::GenericContainer)
1072                .access_live(teksilo_core::accesskit::Live::Off),
1073        );
1074        self.root_child_id = Some(root);
1075
1076        {
1077            let state_enter = state.clone();
1078            ctx.register_action(
1079                Action::new(DEFAULT_INTENT_NAME).on_invoke(move |_intent, ctx| {
1080                    // The focused button wins, and only then the default.
1081                    //
1082                    // This shortcut is widget-scoped, and a scoped shortcut is
1083                    // matched before the focused widget is offered the key, so
1084                    // without this the default answered for a button the user
1085                    // had deliberately tabbed to: standing on Yes and pressing
1086                    // Enter in a `YesNo` box whose default is No said No, shut
1087                    // the dialog, and left the caller's `on_result` looking as
1088                    // though the user had declined. Every desktop toolkit puts
1089                    // the focused push button first here, and a confirmation
1090                    // whose safe answer is the default is exactly where the
1091                    // difference is destructive.
1092                    //
1093                    // `Button` already activates on Enter itself, so this is
1094                    // only about which of the two answers the key, never about
1095                    // making the button work.
1096                    let focused = ctx.focused().and_then(|id| state_enter.button_for(id));
1097                    if let Some(kind) = focused.or_else(|| state_enter.default_button.get()) {
1098                        state_enter.fire(kind, MessageBoxDismissal::Button, ctx);
1099                    }
1100                }),
1101            );
1102            ctx.register_shortcut(
1103                Shortcut::new(DEFAULT_INTENT_NAME)
1104                    .primary(KeyStroke::new(Key::Enter, Modifiers::NONE))
1105                    .build(),
1106            );
1107        }
1108        {
1109            let state_escape = state.clone();
1110            ctx.register_action(
1111                Action::new(ESCAPE_INTENT_NAME).on_invoke(move |_intent, ctx| {
1112                    if let Some(kind) = state_escape.resolve_escape_button() {
1113                        state_escape.fire(kind, MessageBoxDismissal::Escape, ctx);
1114                    } else {
1115                        ctx.dismiss_modal();
1116                    }
1117                }),
1118            );
1119            ctx.register_shortcut(
1120                Shortcut::new(ESCAPE_INTENT_NAME)
1121                    .primary(KeyStroke::new(Key::Escape, Modifiers::NONE))
1122                    .build(),
1123            );
1124        }
1125
1126        if let Some(slot) = &self.state_slot {
1127            *slot.borrow_mut() = Some(state.clone());
1128        }
1129        self.state = Some(state);
1130        vec![root]
1131    }
1132
1133    fn layout_response(
1134        &self,
1135        proposal: SizeProposal,
1136        ctx: &LayoutContext,
1137    ) -> teksilo_core::widget::LayoutResponse {
1138        // Enforce a 460×140 floor without clamping the forwarded
1139        // proposal: the overlay's intrinsic-measurement pass calls us
1140        // with (None, None), and a Spacer-bearing VStack child would
1141        // otherwise collapse to the proposal height. Letting the child
1142        // see the unmodified proposal lets it report its real natural
1143        // size — required so the overlay grows when the "Show details"
1144        // accordion expands.
1145        let child = self
1146            .root_child_id
1147            .and_then(|id| ctx.child_size(id, proposal))
1148            .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
1149        Size::new(child.width.max(460.0), child.height.max(140.0)).into()
1150    }
1151
1152    fn place_children(
1153        &self,
1154        bounds: Rect,
1155        _proposal: SizeProposal,
1156        children: &mut [WidgetPlacement],
1157        _ctx: &LayoutContext,
1158    ) {
1159        for child in children.iter_mut() {
1160            child.origin = bounds.origin();
1161            child.size = bounds.size();
1162        }
1163    }
1164
1165    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
1166
1167    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1168        builder.set_role(teksilo_core::accesskit::Role::AlertDialog);
1169        builder.set_name(self.title.clone());
1170        if let Some(description) = self.accessible_description() {
1171            builder.set_description(description);
1172        }
1173        builder.set_modal();
1174        builder.set_live(teksilo_core::accesskit::Live::Assertive);
1175        builder.add_action(teksilo_core::accesskit::Action::Focus);
1176    }
1177
1178    fn accessible_title_hint(&self) -> Option<String> {
1179        Some(self.title.resolve_now())
1180    }
1181
1182    fn initial_focus_hint(&self) -> Option<WidgetId> {
1183        self.default_button_id.get()
1184    }
1185
1186    fn children(&self) -> Vec<WidgetId> {
1187        self.root_child_id.into_iter().collect()
1188    }
1189}
1190
1191impl MessageBox {
1192    fn accessible_description(&self) -> Option<String> {
1193        match (
1194            self.text.as_ref().map(|t| t.resolve_now()),
1195            self.informative_text.as_ref().map(|i| i.resolve_now()),
1196        ) {
1197            (None, None) => None,
1198            (Some(t), None) => Some(t),
1199            (None, Some(i)) => Some(i),
1200            (Some(t), Some(i)) => Some(format!("{t}\n{i}")),
1201        }
1202    }
1203}
1204
1205/// Extension trait on [`EventContext`] for ergonomic MessageBox
1206/// presentation. Mirrors `ctx.present_modal(...)` for the general
1207/// case.
1208pub trait EventContextMessageBoxExt {
1209    /// Present `mb` as a modal. Equivalent to `mb.present(self)`.
1210    fn present_message_box(&mut self, mb: MessageBox);
1211}
1212
1213impl EventContextMessageBoxExt for EventContext<'_> {
1214    fn present_message_box(&mut self, mb: MessageBox) {
1215        mb.present(self);
1216    }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221    use super::*;
1222    use teksilo_core::ModalContent;
1223    use teksilo_core::event::WidgetEvent;
1224    use teksilo_core::widget_tree::WidgetTree;
1225    use teksilo_i18n::lit;
1226
1227    /// Mirrors the focus resolution `teksilo_app::present_in_tree_modal_request`
1228    /// applies after the modal content subtree is built. Reproduced here
1229    /// because `teksilo-widgets` can't depend on `teksilo-app`.
1230    fn present_and_lay_out(tree: &mut WidgetTree, mb: MessageBox) -> WidgetId {
1231        use crate::button::Button as Btn;
1232        let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(mb)));
1233        let mb_for_closure = mb_cell.clone();
1234        let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
1235            if let Some(mb) = mb_for_closure.borrow_mut().take() {
1236                mb.present(ctx);
1237            }
1238        }));
1239        tree.layout(SizeProposal::exact(800.0, 600.0));
1240        tree.dispatch_event(WidgetEvent::AccessAction {
1241            action: teksilo_core::accesskit::Action::Click,
1242            target: Some(trigger),
1243            target_node: teksilo_core::accessibility::root_node_id(),
1244            data: None,
1245        });
1246        let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1247        let content_id = match request.content {
1248            ModalContent::Deferred(builder) => builder(tree),
1249            ModalContent::ExistingWidget(_) => panic!("MessageBox must use deferred content"),
1250        };
1251        tree.layout(SizeProposal::exact(800.0, 600.0));
1252        let focus_target = request
1253            .focus_target
1254            .filter(|id| tree.is_active(*id) && tree.is_descendant_of(*id, content_id))
1255            .or_else(|| tree.widget_initial_focus_hint(content_id))
1256            .or_else(|| tree.first_focusable_descendant(content_id));
1257        if let Some(id) = focus_target {
1258            tree.focus(id);
1259        }
1260        content_id
1261    }
1262
1263    /// The `MessageBox`, the title widget and the leftmost / rightmost button
1264    /// edges, all in the tree's own bounds space.
1265    struct Placement {
1266        message_box: Rect,
1267        title: Rect,
1268        buttons_left: f32,
1269        buttons_right: f32,
1270    }
1271
1272    fn placement_of(tree: &WidgetTree, content: WidgetId) -> Placement {
1273        let panel = tree.children(content).first().copied().unwrap();
1274        let mb_id = tree.children(panel).first().copied().unwrap();
1275        let mut stack = vec![mb_id];
1276        let mut title_rect = None;
1277        let (mut left, mut right) = (f32::MAX, f32::MIN);
1278        while let Some(id) = stack.pop() {
1279            stack.extend(tree.children(id));
1280            let is = |name: &str| tree.widget_type_name(id).is_some_and(|t| t.ends_with(name));
1281            // The title is the topmost text in the box (`TextWidget` has no
1282            // `Debug` content to match on).
1283            if is("TextWidget") {
1284                let b = tree.bounds(id);
1285                if title_rect.is_none_or(|t: Rect| b.y < t.y) {
1286                    title_rect = Some(b);
1287                }
1288            }
1289            if is("Button") {
1290                let b = tree.bounds(id);
1291                left = left.min(b.x);
1292                right = right.max(b.x + b.width);
1293            }
1294        }
1295        Placement {
1296            message_box: tree.bounds(mb_id),
1297            title: title_rect.expect("title widget"),
1298            buttons_left: left,
1299            buttons_right: right,
1300        }
1301    }
1302
1303    fn placement_with(
1304        title_alignment: Option<HAlignment>,
1305        button_alignment: Option<HAlignment>,
1306    ) -> Placement {
1307        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1308        let mut mb = MessageBox::plain(lit!("ZqTitle"))
1309            .text(lit!(
1310                "A body line that is long enough to make the column wide."
1311            ))
1312            .buttons(MessageBoxButtons::OkCancel);
1313        if let Some(a) = title_alignment {
1314            mb = mb.title_alignment(a);
1315        }
1316        if let Some(a) = button_alignment {
1317            mb = mb.button_alignment(a);
1318        }
1319        let content = present_and_lay_out(&mut tree, mb);
1320        placement_of(&tree, content)
1321    }
1322
1323    #[test]
1324    fn default_alignment_is_leading_title_and_trailing_buttons() {
1325        let p = placement_with(None, None);
1326        let m = p.message_box;
1327        assert!(
1328            (p.title.x - m.x).abs() < 40.0,
1329            "title leads: {:?} in {m:?}",
1330            p.title
1331        );
1332        assert!(
1333            (m.x + m.width - p.buttons_right).abs() < 40.0,
1334            "buttons trail: right edge {} in {m:?}",
1335            p.buttons_right
1336        );
1337        assert!(p.buttons_left > m.x + m.width / 2.0 - 100.0 || m.width < 300.0);
1338    }
1339
1340    #[test]
1341    fn centred_title_and_buttons_sit_in_the_middle() {
1342        let p = placement_with(Some(HAlignment::Center), Some(HAlignment::Center));
1343        let m = p.message_box;
1344        let mid = m.x + m.width / 2.0;
1345        let title_mid = p.title.x + p.title.width / 2.0;
1346        assert!(
1347            (title_mid - mid).abs() < 12.0,
1348            "title centre {title_mid} vs {mid}"
1349        );
1350        let buttons_mid = (p.buttons_left + p.buttons_right) / 2.0;
1351        assert!(
1352            (buttons_mid - mid).abs() < 12.0,
1353            "buttons centre {buttons_mid} vs {mid}"
1354        );
1355    }
1356
1357    #[test]
1358    fn trailing_title_and_leading_buttons_swap_edges() {
1359        let p = placement_with(Some(HAlignment::Trailing), Some(HAlignment::Leading));
1360        let m = p.message_box;
1361        let default = placement_with(None, None);
1362        assert!(
1363            p.title.x > default.title.x + 20.0,
1364            "title moved to the trailing side"
1365        );
1366        assert!(
1367            p.buttons_left < default.buttons_left - 20.0,
1368            "buttons moved to the leading side"
1369        );
1370        assert!(
1371            p.buttons_left < m.x + 60.0,
1372            "buttons lead: {} in {m:?}",
1373            p.buttons_left
1374        );
1375    }
1376
1377    #[test]
1378    fn present_queues_modal_request() {
1379        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1380        let mb = MessageBox::information(lit!("t"))
1381            .text(lit!("x"))
1382            .buttons(MessageBoxButtons::Ok);
1383        let _content = present_and_lay_out(&mut tree, mb);
1384        assert!(tree.find_by_label("t").is_some());
1385    }
1386
1387    #[test]
1388    fn critical_uses_escape_only_close_behavior() {
1389        use crate::button::Button as Btn;
1390        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1391        let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(
1392            MessageBox::critical(lit!("Fatal"))
1393                .text(lit!("Boom"))
1394                .buttons(MessageBoxButtons::Ok),
1395        )));
1396        let mb_for_closure = mb_cell.clone();
1397        let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
1398            if let Some(mb) = mb_for_closure.borrow_mut().take() {
1399                mb.present(ctx);
1400            }
1401        }));
1402        tree.layout(SizeProposal::exact(800.0, 600.0));
1403        tree.dispatch_event(WidgetEvent::AccessAction {
1404            action: teksilo_core::accesskit::Action::Click,
1405            target: Some(trigger),
1406            target_node: teksilo_core::accessibility::root_node_id(),
1407            data: None,
1408        });
1409        let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1410        assert_eq!(request.close_behavior, ModalCloseBehavior::EscapeKey);
1411    }
1412
1413    #[test]
1414    fn alert_dialog_role_exposed() {
1415        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1416        let mb = MessageBox::warning(lit!("Title"))
1417            .text(lit!("Body"))
1418            .buttons(MessageBoxButtons::Ok);
1419        let content = present_and_lay_out(&mut tree, mb);
1420        // `content` is the `ModalContainer`; its child is now the
1421        // `DialogStyle` panel frame, and the `MessageBox` sits one
1422        // level below that.
1423        let panel = tree.children(content).first().copied().unwrap();
1424        let mb_id = tree.children(panel).first().copied().unwrap();
1425        let info = tree.accessibility_node(mb_id);
1426        assert_eq!(info.role(), teksilo_core::accesskit::Role::AlertDialog);
1427        assert_eq!(info.name(), Some("Title"));
1428    }
1429
1430    /// Only the box itself is live. Its content inherited the box's
1431    /// assertive setting through `accesskit_consumer`'s `live()`, so every
1432    /// button and line of text was an announcement of its own.
1433    #[test]
1434    fn nothing_inside_the_box_is_live_on_its_own() {
1435        use teksilo_core::accesskit::{Live, Role};
1436        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1437        let mb = MessageBox::warning(lit!("Supprimer « Vacances » ?"))
1438            .text(lit!("Cet évènement sera retiré de votre liste."))
1439            .buttons(MessageBoxButtons::YesNo)
1440            .default_button(StandardButton::No);
1441        let _content = present_and_lay_out(&mut tree, mb);
1442        let update = tree.sync_accessibility();
1443
1444        let consumer = accesskit_consumer::Tree::new(update, true);
1445        let state = consumer.state();
1446        let mut stack = vec![state.root()];
1447        let mut the_box = None;
1448        let mut inside = Vec::new();
1449        while let Some(node) = stack.pop() {
1450            if node.role() == Role::AlertDialog {
1451                the_box = Some((node.label(), node.live()));
1452                let mut below: Vec<_> = node.children().collect();
1453                while let Some(child) = below.pop() {
1454                    inside.push((child.role(), child.live()));
1455                    below.extend(child.children());
1456                }
1457            }
1458            stack.extend(node.children());
1459        }
1460        assert_eq!(
1461            the_box,
1462            Some((
1463                Some("Supprimer « Vacances » ?".to_string()),
1464                Live::Assertive
1465            )),
1466            "the box stays an assertive live region named by its question"
1467        );
1468        assert!(
1469            inside.iter().any(|(role, _)| *role == Role::Button),
1470            "the walk must reach the buttons for this to say anything"
1471        );
1472        let live_inside: Vec<_> = inside
1473            .iter()
1474            .filter(|(_, live)| *live != Live::Off)
1475            .collect();
1476        assert!(
1477            live_inside.is_empty(),
1478            "live inside the box: {live_inside:?}"
1479        );
1480    }
1481
1482    /// What opening the box used to announce: the focused button, as an
1483    /// assertive live region of its own, which interrupts. It is announced as
1484    /// the focus now, the way any focused control is, and not a second time.
1485    /// What the box announces is its question, once: a ring that heard
1486    /// nothing at all would pass the first half of this.
1487    #[test]
1488    fn opening_the_box_does_not_announce_its_buttons() {
1489        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1490        tree.layout(SizeProposal::exact(800.0, 600.0));
1491        let _ = tree.sync_accessibility();
1492        let seen = tree.announcements_since(0).last().map_or(0, |a| a.seq);
1493        let mb = MessageBox::warning(lit!("Supprimer « Vacances » ?"))
1494            .text(lit!("Cet évènement sera retiré de votre liste."))
1495            .buttons(MessageBoxButtons::YesNo)
1496            .default_button(StandardButton::No);
1497        let _content = present_and_lay_out(&mut tree, mb);
1498        let _ = tree.sync_accessibility();
1499        let heard: Vec<String> = tree
1500            .announcements_since(seen)
1501            .into_iter()
1502            .map(|a| a.text)
1503            .collect();
1504        let yes = StandardButton::Yes.default_label().resolve_now();
1505        let no = StandardButton::No.default_label().resolve_now();
1506        assert!(
1507            !heard
1508                .iter()
1509                .any(|t| *t == yes || *t == no || t.starts_with("Cet évènement")),
1510            "heard {heard:?}"
1511        );
1512        assert_eq!(
1513            heard,
1514            ["Supprimer « Vacances » ?"],
1515            "the question, once, and nothing else"
1516        );
1517    }
1518
1519    #[test]
1520    fn title_label_is_hidden_but_body_label_survives() {
1521        // The MessageBox's own AlertDialog node already carries the title
1522        // as its name (set on the node, not drawn through `labelled_by`, so
1523        // that a new title is a change of the node the adapters announce),
1524        // so the embedded title Label must not
1525        // reach the AT tree as a second, duplicate-named stop. The body
1526        // text, which is not a duplicate, must still be present.
1527        use teksilo_core::accessibility::widget_id_to_node_id;
1528        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1529        let mb = MessageBox::warning(lit!("Title"))
1530            .text(lit!("Body"))
1531            .buttons(MessageBoxButtons::Ok);
1532        let content = present_and_lay_out(&mut tree, mb);
1533        let panel = tree.children(content).first().copied().unwrap();
1534        let mb_id = tree.children(panel).first().copied().unwrap();
1535
1536        let update = tree.sync_accessibility();
1537
1538        let mb_node_id = widget_id_to_node_id(mb_id);
1539        let mb_node = update
1540            .nodes
1541            .iter()
1542            .find(|(nid, _)| *nid == mb_node_id)
1543            .map(|(_, node)| node)
1544            .expect("MessageBox node");
1545        assert_eq!(mb_node.role(), teksilo_core::accesskit::Role::AlertDialog);
1546        assert_eq!(
1547            mb_node.label(),
1548            Some("Title"),
1549            "hiding the title label must not take the dialog's own name away with it",
1550        );
1551
1552        assert!(
1553            !update.nodes.iter().any(|(_, node)| node.role()
1554                == teksilo_core::accesskit::Role::Label
1555                && teksilo_core::accessibility::announced_text(node) == Some("Title")),
1556            "the title Label must not survive as a duplicate-named node",
1557        );
1558
1559        assert!(
1560            update.nodes.iter().any(|(_, node)| node.role()
1561                == teksilo_core::accesskit::Role::Label
1562                && teksilo_core::accessibility::announced_text(node) == Some("Body")),
1563            "the body label is not a name duplicate and must stay visible",
1564        );
1565    }
1566
1567    #[test]
1568    fn ok_button_fires_result_with_correct_kind() {
1569        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1570        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1571        let captured_for_handler = captured.clone();
1572        let mb = MessageBox::information(lit!("t"))
1573            .text(lit!("x"))
1574            .buttons(MessageBoxButtons::Ok)
1575            .on_result(move |r, _ctx| {
1576                *captured_for_handler.borrow_mut() = Some(r);
1577            });
1578        let _content = present_and_lay_out(&mut tree, mb);
1579        let ok_id = tree
1580            .find_by_label(&StandardButton::Ok.default_label().resolve_now())
1581            .unwrap();
1582        tree.dispatch_event(WidgetEvent::AccessAction {
1583            action: teksilo_core::accesskit::Action::Click,
1584            target: Some(ok_id),
1585            target_node: teksilo_core::accessibility::root_node_id(),
1586            data: None,
1587        });
1588        let result = captured.borrow().expect("result must be captured");
1589        assert_eq!(result.button, StandardButton::Ok);
1590        assert!(!result.checkbox_checked);
1591        assert_eq!(result.dismissal, MessageBoxDismissal::Button);
1592    }
1593
1594    #[test]
1595    fn default_button_is_focused_on_open() {
1596        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1597        let mb = MessageBox::question(lit!("t"))
1598            .text(lit!("x"))
1599            .buttons(MessageBoxButtons::YesNoCancel)
1600            .default_button(StandardButton::No);
1601        let _content = present_and_lay_out(&mut tree, mb);
1602        let no_id = tree
1603            .find_by_label(&StandardButton::No.default_label().resolve_now())
1604            .unwrap();
1605        assert_eq!(tree.focused(), Some(no_id));
1606    }
1607
1608    /// The reason this preset default exists. A Yes/No box is a confirmation,
1609    /// a confirmation usually precedes something destructive, and Enter is what
1610    /// a keyboard user presses on a dialog they have not finished reading.
1611    ///
1612    /// Focus placement is the whole contract: `Node::keyboard_shortcut` appears
1613    /// in none of the three AccessKit adapters, so no platform announces which
1614    /// button is the default. A screen-reader user finds out by triggering it.
1615    #[test]
1616    fn a_yes_no_box_defaults_to_no_so_enter_cannot_destroy() {
1617        for buttons in [MessageBoxButtons::YesNo, MessageBoxButtons::YesNoCancel] {
1618            let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1619            let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1620            let captured_for_handler = captured.clone();
1621            let mb = MessageBox::question(lit!("t"))
1622                .text(lit!("x"))
1623                .buttons(buttons.clone())
1624                .on_result(move |r, _ctx| {
1625                    *captured_for_handler.borrow_mut() = Some(r);
1626                });
1627            let _content = present_and_lay_out(&mut tree, mb);
1628
1629            let no_id = tree
1630                .find_by_label(&StandardButton::No.default_label().resolve_now())
1631                .unwrap();
1632            assert_eq!(
1633                tree.focused(),
1634                Some(no_id),
1635                "{buttons:?} must open with No focused"
1636            );
1637
1638            tree.press_key(Key::Enter, Modifiers::NONE);
1639            let result = captured.borrow().expect("result must be captured");
1640            assert_eq!(
1641                result.button,
1642                StandardButton::No,
1643                "{buttons:?}: Enter on an unread confirmation must not answer Yes"
1644            );
1645        }
1646    }
1647
1648    /// Escape and Enter are different buttons for `YesNoCancel`, and both
1649    /// matter: Escape means "I did not mean to be in this dialog", which is
1650    /// Cancel, while Enter answers the question that was asked.
1651    #[test]
1652    fn yes_no_cancel_keeps_cancel_as_the_escape_button() {
1653        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1654        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1655        let captured_for_handler = captured.clone();
1656        let mb = MessageBox::question(lit!("t"))
1657            .text(lit!("x"))
1658            .buttons(MessageBoxButtons::YesNoCancel)
1659            .on_result(move |r, _ctx| {
1660                *captured_for_handler.borrow_mut() = Some(r);
1661            });
1662        let _content = present_and_lay_out(&mut tree, mb);
1663        tree.press_key(Key::Escape, Modifiers::NONE);
1664        let result = captured.borrow().expect("result must be captured");
1665        assert_eq!(result.button, StandardButton::Cancel);
1666        assert_eq!(result.dismissal, MessageBoxDismissal::Escape);
1667    }
1668
1669    /// The override still works, and is how a box that asks something safe
1670    /// gets Yes back.
1671    #[test]
1672    fn default_button_overrides_the_preset() {
1673        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1674        let mb = MessageBox::question(lit!("t"))
1675            .text(lit!("x"))
1676            .buttons(MessageBoxButtons::YesNo)
1677            .default_button(StandardButton::Yes);
1678        let _content = present_and_lay_out(&mut tree, mb);
1679        let yes_id = tree
1680            .find_by_label(&StandardButton::Yes.default_label().resolve_now())
1681            .unwrap();
1682        assert_eq!(tree.focused(), Some(yes_id));
1683    }
1684
1685    /// Enter answers for the button the user is standing on.
1686    ///
1687    /// This asserted the opposite until 0.9.3, and the opposite is what every
1688    /// caller of a `YesNo` box was living with: the shortcut that carries
1689    /// Enter is widget-scoped, so it is matched before the focused widget is
1690    /// offered the key, and the default answered for a button the user had
1691    /// deliberately tabbed to. In a confirmation whose safe answer is the
1692    /// default, that turns a deliberate Yes into a silent No.
1693    #[test]
1694    fn enter_fires_the_focused_button_rather_than_the_default() {
1695        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1696        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1697        let captured_for_handler = captured.clone();
1698        let mb = MessageBox::question(lit!("t"))
1699            .text(lit!("x"))
1700            .buttons(MessageBoxButtons::OkCancel)
1701            .on_result(move |r, _ctx| {
1702                *captured_for_handler.borrow_mut() = Some(r);
1703            });
1704        let _content = present_and_lay_out(&mut tree, mb);
1705        let cancel_id = tree
1706            .find_by_label(&StandardButton::Cancel.default_label().resolve_now())
1707            .unwrap();
1708        tree.focus(cancel_id);
1709        tree.press_key(Key::Enter, Modifiers::NONE);
1710        let result = captured.borrow().expect("result must be captured");
1711        assert_eq!(
1712            result.button,
1713            StandardButton::Cancel,
1714            "Enter must answer for the focused button, not for the default"
1715        );
1716        assert_eq!(result.dismissal, MessageBoxDismissal::Button);
1717    }
1718
1719    /// And the default still answers when the focus is not on a button.
1720    ///
1721    /// The "show again" checkbox is the case that exists in a real box: it
1722    /// takes focus, and it handles Space rather than Enter, so Enter reaches
1723    /// the shortcut with the focus somewhere that is not one of the answers.
1724    #[test]
1725    fn enter_still_fires_the_default_when_the_focus_is_not_a_button() {
1726        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1727        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1728        let captured_for_handler = captured.clone();
1729        let mb = MessageBox::question(lit!("t"))
1730            .text(lit!("x"))
1731            .buttons(MessageBoxButtons::OkCancel)
1732            .show_again_checkbox(lit!("Ne plus demander"))
1733            .on_result(move |r, _ctx| {
1734                *captured_for_handler.borrow_mut() = Some(r);
1735            });
1736        let _content = present_and_lay_out(&mut tree, mb);
1737        let checkbox_id = tree.find_by_label("Ne plus demander").unwrap();
1738        tree.focus(checkbox_id);
1739        tree.press_key(Key::Enter, Modifiers::NONE);
1740        let result = captured.borrow().expect("result must be captured");
1741        assert_eq!(result.button, StandardButton::Ok);
1742        assert_eq!(result.dismissal, MessageBoxDismissal::Button);
1743    }
1744
1745    #[test]
1746    fn escape_fires_escape_button_and_marks_dismissed() {
1747        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1748        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1749        let captured_for_handler = captured.clone();
1750        let mb = MessageBox::question(lit!("t"))
1751            .text(lit!("x"))
1752            .buttons(MessageBoxButtons::YesNoCancel)
1753            .on_result(move |r, _ctx| {
1754                *captured_for_handler.borrow_mut() = Some(r);
1755            });
1756        let _content = present_and_lay_out(&mut tree, mb);
1757        tree.press_key(Key::Escape, Modifiers::NONE);
1758        let result = captured.borrow().expect("result must be captured");
1759        assert_eq!(result.button, StandardButton::Cancel);
1760        assert_eq!(result.dismissal, MessageBoxDismissal::Escape);
1761    }
1762
1763    #[test]
1764    fn checkbox_state_reported_in_result() {
1765        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1766        let shared_state = Signal::new(false);
1767        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1768        let captured_for_handler = captured.clone();
1769        let mb = MessageBox::information(lit!("t"))
1770            .text(lit!("x"))
1771            .buttons(MessageBoxButtons::Ok)
1772            .show_again_checkbox_state(shared_state.clone())
1773            .show_again_checkbox(lit!("Don't show again"))
1774            .on_result(move |r, _ctx| {
1775                *captured_for_handler.borrow_mut() = Some(r);
1776            });
1777        let _content = present_and_lay_out(&mut tree, mb);
1778        shared_state.set(true);
1779        let ok_id = tree
1780            .find_by_label(&StandardButton::Ok.default_label().resolve_now())
1781            .unwrap();
1782        tree.dispatch_event(WidgetEvent::AccessAction {
1783            action: teksilo_core::accesskit::Action::Click,
1784            target: Some(ok_id),
1785            target_node: teksilo_core::accessibility::root_node_id(),
1786            data: None,
1787        });
1788        assert!(captured.borrow().unwrap().checkbox_checked);
1789    }
1790
1791    #[test]
1792    fn the_presenting_container_is_no_second_dialog() {
1793        // This test used to ask for the container to be a `Role::Dialog`
1794        // named by the box's title, which was the one dialog a reader met
1795        // while the panel between them hid the box. With the box reachable
1796        // that is a second dialog of the same name around its
1797        // `AlertDialog`, and Orca speaks each dialog a focus change enters,
1798        // so the title was heard twice. The box is the dialog; the
1799        // container publishes a bare `GenericContainer`, which every
1800        // adapter drops.
1801        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1802        let mb = MessageBox::information(lit!("Title propagation test"))
1803            .text(lit!("Body"))
1804            .buttons(MessageBoxButtons::Ok);
1805        let content = present_and_lay_out(&mut tree, mb);
1806        let container = tree.accessibility_node(content);
1807        assert_eq!(
1808            container.role(),
1809            teksilo_core::accesskit::Role::GenericContainer
1810        );
1811        assert_eq!(container.name(), None);
1812        let panel = tree.children(content).first().copied().unwrap();
1813        let mb_id = tree.children(panel).first().copied().unwrap();
1814        let info = tree.accessibility_node(mb_id);
1815        assert_eq!(info.role(), teksilo_core::accesskit::Role::AlertDialog);
1816        assert_eq!(info.name(), Some("Title propagation test"));
1817    }
1818
1819    #[test]
1820    fn standard_button_roles_classify_correctly() {
1821        assert_eq!(StandardButton::Ok.role(), ButtonRole::Accept);
1822        assert_eq!(StandardButton::Yes.role(), ButtonRole::Accept);
1823        assert_eq!(StandardButton::Save.role(), ButtonRole::Accept);
1824        assert_eq!(StandardButton::Cancel.role(), ButtonRole::Reject);
1825        assert_eq!(StandardButton::No.role(), ButtonRole::Reject);
1826        assert_eq!(StandardButton::Abort.role(), ButtonRole::Reject);
1827        assert_eq!(StandardButton::Discard.role(), ButtonRole::Destructive);
1828        assert_eq!(StandardButton::Help.role(), ButtonRole::Action);
1829        assert_eq!(StandardButton::Ignore.role(), ButtonRole::Action);
1830    }
1831
1832    /// A long `detailed_text` expands, stays reachable, and lays out without blowing up.
1833    ///
1834    /// The pane is where callers put the things with no length bound — a stack trace, an OS
1835    /// error dump, a list of every affected row — and it used to be a bare `TextWidget` in
1836    /// the accordion, so expanding it grew the dialog until the button row went off the
1837    /// bottom of the screen. It is capped and scrollable now (`DETAILS_MAX_HEIGHT`).
1838    ///
1839    /// This pins the reachable half: a hundred lines of detail still build, expand and
1840    /// re-lay out with the buttons intact. The *height* cap itself is not asserted here —
1841    /// `WidgetTree` exposes no laid-out geometry to widget tests, so there is nothing to
1842    /// measure against; that half is the `ScrollArea::preferred_height` contract, which
1843    /// `scroll_area` owns and tests.
1844    #[test]
1845    fn a_long_details_pane_expands_and_keeps_the_dialog_intact() {
1846        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1847        let long: String = (1..=100)
1848            .map(|i| format!("line {i} of a very long detail dump\n"))
1849            .collect();
1850        let mb = MessageBox::critical(lit!("Could not open file"))
1851            .text(lit!("It went wrong."))
1852            .detailed_text(lit!(long))
1853            .buttons(MessageBoxButtons::Ok);
1854        let _content = present_and_lay_out(&mut tree, mb);
1855
1856        let toggle = tree
1857            .find_by_label(&teksilo_i18n::tr_widget!(messagebox_show_details()).resolve_now())
1858            .expect("the Show details toggle");
1859        tree.dispatch_event(WidgetEvent::AccessAction {
1860            action: teksilo_core::accesskit::Action::Click,
1861            target: Some(toggle),
1862            target_node: teksilo_core::accessibility::root_node_id(),
1863            data: None,
1864        });
1865        tree.layout(SizeProposal::exact(800.0, 600.0));
1866
1867        // The dialog is still whole: its title and its button both survived the expansion.
1868        assert!(tree.find_by_label("Could not open file").is_some());
1869        assert!(
1870            tree.find_by_label(&StandardButton::Ok.default_label().resolve_now())
1871                .is_some(),
1872            "the button row must survive an expanded details pane"
1873        );
1874    }
1875
1876    #[test]
1877    fn escape_resolution_prefers_explicit_escape_button() {
1878        let state = State::new(Signal::new(false));
1879        *state.buttons.borrow_mut() = vec![StandardButton::Save, StandardButton::Discard];
1880        state.escape_button.set(Some(StandardButton::Discard));
1881        assert_eq!(state.resolve_escape_button(), Some(StandardButton::Discard));
1882    }
1883
1884    #[test]
1885    fn escape_resolution_falls_back_to_first_reject() {
1886        let state = State::new(Signal::new(false));
1887        *state.buttons.borrow_mut() = vec![
1888            StandardButton::Retry,
1889            StandardButton::Ignore,
1890            StandardButton::Abort,
1891        ];
1892        state.escape_button.set(None);
1893        assert_eq!(state.resolve_escape_button(), Some(StandardButton::Abort));
1894    }
1895
1896    #[test]
1897    fn escape_resolution_falls_back_to_last_when_no_reject() {
1898        let state = State::new(Signal::new(false));
1899        *state.buttons.borrow_mut() = vec![StandardButton::Ok, StandardButton::Help];
1900        state.escape_button.set(None);
1901        assert_eq!(state.resolve_escape_button(), Some(StandardButton::Help));
1902    }
1903}