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//! ## Result reporting
87//!
88//! [`MessageBox::on_result`] takes `impl Fn(MessageBoxResult,
89//! &mut EventContext) + 'static`. The callback fires exactly once — on
90//! button activation or Escape dismissal — then the modal is closed by
91//! the framework.
92//!
93//! ## Accessibility
94//!
95//! The widget exposes `Role::AlertDialog` (distinct from
96//! `ModalContainer`'s `Role::Dialog`), with `set_modal()`,
97//! `set_live(Live::Assertive)`, `set_name(title)`, and
98//! `set_description(text + informative_text)` so screen readers
99//! announce the dialog and its body on open.
100
101use std::cell::{Cell, RefCell};
102use std::rc::Rc;
103
104use teksilo_canvas::{Rect, Size, SizeProposal};
105use teksilo_core::accessibility::AccessNodeBuilder;
106use teksilo_core::action::Action;
107use teksilo_core::build_context::BuildContext;
108use teksilo_core::event::{Key, Modifiers};
109use teksilo_core::modal::{ModalCloseBehavior, ModalPresentation, ModalRequest};
110use teksilo_core::shortcut::{KeyStroke, Shortcut};
111use teksilo_core::signal::Signal;
112use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
113use teksilo_core::widget_id::WidgetId;
114use teksilo_i18n::LocalizedString;
115use teksilo_tokens::VAlignment;
116
117use crate::accordion::Accordion;
118use crate::button::{Button, ButtonVariant};
119use crate::checkbox::Checkbox;
120use crate::dialog::ModalContainer;
121use crate::primitives::{Expand, HStack, Spacer, TextWidget, VStack};
122use crate::scroll_area::ScrollArea;
123use crate::severity_badge::{SeverityBadge, SeverityIconKind};
124
125// ── Severity ────────────────────────────────────────────────────────
126
127/// Alert severity level. Drives the icon glyph + tint shown beside the
128/// title, and (for `Critical`) whether click-outside dismiss is enabled.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130pub enum MessageBoxSeverity {
131    /// No icon. Use for plain notices where an icon would be noise.
132    #[default]
133    None,
134    /// Informational notice — blue circle with "i" glyph.
135    Information,
136    /// Confirmation prompt — accent-tinted circle with "?" glyph.
137    Question,
138    /// Non-fatal warning — amber triangle with "!" glyph.
139    Warning,
140    /// Critical error — red circle with an "X" glyph. Click-outside
141    /// dismissal is disabled (Escape still works).
142    Critical,
143}
144
145// ── Standard button catalog ─────────────────────────────────────────
146
147/// Semantic role of a message-box button. Used for fallback escape
148/// resolution (`Reject` wins when no explicit escape button is set).
149/// Teksilo deliberately does **not** render `Destructive` buttons with
150/// a red fill — the dialog's severity icon and wording carry that
151/// signal. See [`crate::button`] for the framework-level rationale.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum ButtonRole {
154    /// Confirms / proceeds. Ok, Yes, Save, Open, Apply, Retry.
155    Accept,
156    /// Bails out. Cancel, Close, No, Abort.
157    Reject,
158    /// Data-loss action. Discard. (Same visuals as Regular — the
159    /// severity of the surrounding MessageBox carries the warning.)
160    Destructive,
161    /// Side action. Help, Reset, RestoreDefaults, Ignore, and the
162    /// "to all" variants.
163    Action,
164}
165
166/// The Qt-modeled catalog of standard buttons. Each variant resolves
167/// to a localized label, a semantic [`ButtonRole`], and a stable
168/// intent-name string used internally for shortcut/action routing.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
170pub enum StandardButton {
171    /// Accept / confirm. `ButtonRole::Accept`.
172    Ok,
173    /// Cancel the operation. `ButtonRole::Reject`.
174    Cancel,
175    /// Close the dialog. `ButtonRole::Reject`.
176    Close,
177    /// Confirm with "Yes". `ButtonRole::Accept`.
178    Yes,
179    /// Decline with "No". `ButtonRole::Reject`.
180    No,
181    /// Confirm all remaining items. `ButtonRole::Accept`.
182    YesToAll,
183    /// Decline all remaining items. `ButtonRole::Reject`.
184    NoToAll,
185    /// Save changes. `ButtonRole::Accept`.
186    Save,
187    /// Save all open items. `ButtonRole::Accept`.
188    SaveAll,
189    /// Discard changes without saving. `ButtonRole::Destructive`.
190    Discard,
191    /// Apply changes without closing. `ButtonRole::Accept`.
192    Apply,
193    /// Reset to defaults. `ButtonRole::Action`.
194    Reset,
195    /// Restore factory defaults. `ButtonRole::Action`.
196    RestoreDefaults,
197    /// Abort the current operation. `ButtonRole::Reject`.
198    Abort,
199    /// Retry the failed operation. `ButtonRole::Accept`.
200    Retry,
201    /// Ignore the error and continue. `ButtonRole::Action`.
202    Ignore,
203    /// Open a file or resource. `ButtonRole::Accept`.
204    Open,
205    /// Show help. `ButtonRole::Action`.
206    Help,
207}
208
209impl StandardButton {
210    /// The button's semantic role — used internally by MessageBox's
211    /// escape-button fallback resolution, and available to callers that
212    /// want to inspect a `MessageBoxButton`'s role.
213    pub fn role(self) -> ButtonRole {
214        match self {
215            Self::Ok
216            | Self::Yes
217            | Self::YesToAll
218            | Self::Save
219            | Self::SaveAll
220            | Self::Apply
221            | Self::Retry
222            | Self::Open => ButtonRole::Accept,
223            Self::Cancel | Self::Close | Self::No | Self::NoToAll | Self::Abort => {
224                ButtonRole::Reject
225            }
226            Self::Discard => ButtonRole::Destructive,
227            Self::Reset | Self::RestoreDefaults | Self::Ignore | Self::Help => ButtonRole::Action,
228        }
229    }
230
231    /// Stable string id used as both the shortcut id and the intent
232    /// name for routing default/escape key activations. Scoped to a
233    /// MessageBox instance via widget-scoped shortcut registration, so
234    /// the same id is safe to reuse across instances.
235    pub fn intent_name(self) -> &'static str {
236        match self {
237            Self::Ok => "messagebox.btn.ok",
238            Self::Cancel => "messagebox.btn.cancel",
239            Self::Close => "messagebox.btn.close",
240            Self::Yes => "messagebox.btn.yes",
241            Self::No => "messagebox.btn.no",
242            Self::YesToAll => "messagebox.btn.yes_to_all",
243            Self::NoToAll => "messagebox.btn.no_to_all",
244            Self::Save => "messagebox.btn.save",
245            Self::SaveAll => "messagebox.btn.save_all",
246            Self::Discard => "messagebox.btn.discard",
247            Self::Apply => "messagebox.btn.apply",
248            Self::Reset => "messagebox.btn.reset",
249            Self::RestoreDefaults => "messagebox.btn.restore_defaults",
250            Self::Abort => "messagebox.btn.abort",
251            Self::Retry => "messagebox.btn.retry",
252            Self::Ignore => "messagebox.btn.ignore",
253            Self::Open => "messagebox.btn.open",
254            Self::Help => "messagebox.btn.help",
255        }
256    }
257
258    /// Default label for the button. Resolved through the Fluent
259    /// catalog via `tr_widget!` so apps can override per-locale.
260    pub fn default_label(self) -> LocalizedString {
261        match self {
262            Self::Ok => teksilo_i18n::tr_widget!(messagebox_btn_ok()),
263            Self::Cancel => teksilo_i18n::tr_widget!(messagebox_btn_cancel()),
264            Self::Close => teksilo_i18n::tr_widget!(messagebox_btn_close()),
265            Self::Yes => teksilo_i18n::tr_widget!(messagebox_btn_yes()),
266            Self::No => teksilo_i18n::tr_widget!(messagebox_btn_no()),
267            Self::YesToAll => teksilo_i18n::tr_widget!(messagebox_btn_yes_to_all()),
268            Self::NoToAll => teksilo_i18n::tr_widget!(messagebox_btn_no_to_all()),
269            Self::Save => teksilo_i18n::tr_widget!(messagebox_btn_save()),
270            Self::SaveAll => teksilo_i18n::tr_widget!(messagebox_btn_save_all()),
271            Self::Discard => teksilo_i18n::tr_widget!(messagebox_btn_discard()),
272            Self::Apply => teksilo_i18n::tr_widget!(messagebox_btn_apply()),
273            Self::Reset => teksilo_i18n::tr_widget!(messagebox_btn_reset()),
274            Self::RestoreDefaults => teksilo_i18n::tr_widget!(messagebox_btn_restore_defaults()),
275            Self::Abort => teksilo_i18n::tr_widget!(messagebox_btn_abort()),
276            Self::Retry => teksilo_i18n::tr_widget!(messagebox_btn_retry()),
277            Self::Ignore => teksilo_i18n::tr_widget!(messagebox_btn_ignore()),
278            Self::Open => teksilo_i18n::tr_widget!(messagebox_btn_open()),
279            Self::Help => teksilo_i18n::tr_widget!(messagebox_btn_help()),
280        }
281    }
282}
283
284/// A single button placement inside a MessageBox, including an optional
285/// per-instance label override. Callers usually build these via
286/// [`From<StandardButton>`] (`StandardButton::Ok.into()`), or
287/// construct them manually when `Custom` is needed.
288#[derive(Debug, Clone)]
289pub struct MessageBoxButton {
290    /// Which standard button this is — drives role, intent name, and
291    /// (when `label_override` is `None`) the label.
292    pub kind: StandardButton,
293    /// Optional explicit label that overrides `kind.default_label()`.
294    /// Use sparingly: prefer translating the default via Fluent rather
295    /// than hard-coding per-call labels.
296    pub label_override: Option<LocalizedString>,
297}
298
299impl MessageBoxButton {
300    /// Build a button from a `StandardButton` with the default label.
301    pub fn standard(kind: StandardButton) -> Self {
302        Self {
303            kind,
304            label_override: None,
305        }
306    }
307
308    /// Override the default translated label.
309    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
310        self.label_override = Some(label.into());
311        self
312    }
313
314    fn resolved_label(&self) -> LocalizedString {
315        self.label_override
316            .clone()
317            .unwrap_or_else(|| self.kind.default_label())
318    }
319}
320
321impl From<StandardButton> for MessageBoxButton {
322    fn from(kind: StandardButton) -> Self {
323        Self::standard(kind)
324    }
325}
326
327/// Pre-built button bundles covering the common MessageBox shapes.
328/// Custom combinations go through [`MessageBox::add_button`] or
329/// [`MessageBoxButtons::Custom`].
330#[derive(Debug, Clone)]
331pub enum MessageBoxButtons {
332    /// Just Ok.
333    Ok,
334    /// Ok + Cancel, Ok default, Cancel escape.
335    OkCancel,
336    /// Yes + No. **No** is the default, and No is the escape. See the module
337    /// docs for why the default is the negative answer.
338    YesNo,
339    /// Yes + No + Cancel. **No** is the default, Cancel is the escape — Enter
340    /// takes the safe answer to the question asked, Escape leaves the dialog.
341    YesNoCancel,
342    /// The unsaved-changes triad: Save + Discard + Cancel.
343    SaveDiscardCancel,
344    /// The error-recovery triad: Retry + Ignore + Abort.
345    RetryIgnoreAbort,
346    /// Explicit list. MessageBox preserves the order as the visual
347    /// button order (leading Spacer pushes all buttons to the trailing
348    /// edge; default button may appear anywhere).
349    Custom(Vec<MessageBoxButton>),
350}
351
352impl MessageBoxButtons {
353    fn into_buttons(self) -> Vec<MessageBoxButton> {
354        match self {
355            Self::Ok => vec![StandardButton::Ok.into()],
356            Self::OkCancel => vec![StandardButton::Cancel.into(), StandardButton::Ok.into()],
357            Self::YesNo => vec![StandardButton::No.into(), StandardButton::Yes.into()],
358            Self::YesNoCancel => vec![
359                StandardButton::Cancel.into(),
360                StandardButton::No.into(),
361                StandardButton::Yes.into(),
362            ],
363            Self::SaveDiscardCancel => vec![
364                StandardButton::Discard.into(),
365                StandardButton::Cancel.into(),
366                StandardButton::Save.into(),
367            ],
368            Self::RetryIgnoreAbort => vec![
369                StandardButton::Abort.into(),
370                StandardButton::Ignore.into(),
371                StandardButton::Retry.into(),
372            ],
373            Self::Custom(items) => items,
374        }
375    }
376
377    /// Default button hint derived from the preset. Callers that want
378    /// a different default override via `MessageBox::default_button`.
379    ///
380    /// `Ok` and `OkCancel` default to Ok because an Ok/Cancel box asks the user
381    /// to confirm something they just asked for. `YesNo` and `YesNoCancel`
382    /// default to **No**, because a Yes/No box asks a question the user did not
383    /// initiate, and the answer that cannot be undone is Yes. See the variant
384    /// docs.
385    fn preset_default(&self) -> Option<StandardButton> {
386        match self {
387            Self::Ok => Some(StandardButton::Ok),
388            Self::OkCancel => Some(StandardButton::Ok),
389            Self::YesNo => Some(StandardButton::No),
390            Self::YesNoCancel => Some(StandardButton::No),
391            Self::SaveDiscardCancel => Some(StandardButton::Save),
392            Self::RetryIgnoreAbort => Some(StandardButton::Retry),
393            Self::Custom(_) => None,
394        }
395    }
396
397    /// Escape button hint derived from the preset.
398    fn preset_escape(&self) -> Option<StandardButton> {
399        match self {
400            Self::Ok => Some(StandardButton::Ok),
401            Self::OkCancel => Some(StandardButton::Cancel),
402            Self::YesNo => Some(StandardButton::No),
403            Self::YesNoCancel => Some(StandardButton::Cancel),
404            Self::SaveDiscardCancel => Some(StandardButton::Cancel),
405            Self::RetryIgnoreAbort => Some(StandardButton::Abort),
406            Self::Custom(_) => None,
407        }
408    }
409}
410
411// ── Result ──────────────────────────────────────────────────────────
412
413/// Report passed to [`MessageBox::on_result`] when the dialog closes.
414#[derive(Debug, Clone, Copy)]
415pub struct MessageBoxResult {
416    /// Which button fired — either by click, Enter (default button),
417    /// or Escape (escape button resolution).
418    pub button: StandardButton,
419    /// State of the "Don't show again" checkbox at dismiss time, when
420    /// one was configured via [`MessageBox::show_again_checkbox`] or
421    /// [`MessageBox::show_again_checkbox_state`]. `false` when no
422    /// checkbox was attached.
423    pub checkbox_checked: bool,
424    /// `true` when the user dismissed via Escape (or scrim-click, when
425    /// permitted) rather than clicking a button directly.
426    pub dismissed_by_escape: bool,
427}
428
429const SEVERITY_ICON_SIZE: f32 = 48.0;
430
431/// How tall the expanded "Show details" pane is allowed to grow before it scrolls.
432///
433/// `detailed_text` is where callers put the things that have no length bound — a stack
434/// trace, an OS error dump, a list of every affected row — so without a cap the accordion
435/// simply grew the dialog until it ran off the bottom of the screen, taking the buttons
436/// with it. Tall enough for a dozen lines of `small` text, short enough that the dialog
437/// still fits a laptop display beneath a header and a button row.
438const DETAILS_MAX_HEIGHT: f32 = 220.0;
439
440const DEFAULT_INTENT_NAME: &str = "messagebox.accept_default";
441const ESCAPE_INTENT_NAME: &str = "messagebox.escape";
442
443/// Map a `MessageBoxSeverity` onto the shared severity-badge kind.
444/// `None` has no badge (the dialog reads as a plain notice).
445fn severity_icon_kind(severity: MessageBoxSeverity) -> Option<SeverityIconKind> {
446    match severity {
447        MessageBoxSeverity::None => None,
448        MessageBoxSeverity::Information => Some(SeverityIconKind::Info),
449        MessageBoxSeverity::Question => Some(SeverityIconKind::Question),
450        MessageBoxSeverity::Warning => Some(SeverityIconKind::Warning),
451        MessageBoxSeverity::Critical => Some(SeverityIconKind::Error),
452    }
453}
454
455// ── Internal runtime state ─────────────────────────────────────────
456
457/// State shared between the MessageBox widget, its footer buttons, and
458/// the Enter/Escape shortcut actions. Lives in an `Rc` so all three
459/// dispatch paths can read/write the same checkbox state and fire the
460/// same result callback exactly once per session.
461struct State {
462    on_result: RefCell<Option<Box<dyn Fn(MessageBoxResult, &mut EventContext)>>>,
463    checkbox: Signal<bool>,
464    escape_button: Cell<Option<StandardButton>>,
465    default_button: Cell<Option<StandardButton>>,
466    /// Fallback list of buttons in the order they were configured —
467    /// consulted when neither `escape_button` nor a `Reject`-role
468    /// button is set.
469    buttons: RefCell<Vec<StandardButton>>,
470    /// Guards against multiple result-callback invocations when a
471    /// button click races the Escape shortcut.
472    fired: Cell<bool>,
473}
474
475impl State {
476    fn new(checkbox: Signal<bool>) -> Rc<Self> {
477        Rc::new(Self {
478            on_result: RefCell::new(None),
479            checkbox,
480            escape_button: Cell::new(None),
481            default_button: Cell::new(None),
482            buttons: RefCell::new(Vec::new()),
483            fired: Cell::new(false),
484        })
485    }
486
487    fn fire(&self, button: StandardButton, by_escape: bool, ctx: &mut EventContext) {
488        if self.fired.replace(true) {
489            return;
490        }
491        let result = MessageBoxResult {
492            button,
493            checkbox_checked: self.checkbox.get(),
494            dismissed_by_escape: by_escape,
495        };
496        if let Some(handler) = self.on_result.borrow().as_ref() {
497            handler(result, ctx);
498        }
499        ctx.dismiss_modal();
500    }
501
502    fn resolve_escape_button(&self) -> Option<StandardButton> {
503        if let Some(btn) = self.escape_button.get() {
504            return Some(btn);
505        }
506        let buttons = self.buttons.borrow();
507        if let Some(btn) = buttons.iter().find(|b| b.role() == ButtonRole::Reject) {
508            return Some(*btn);
509        }
510        if buttons.contains(&StandardButton::Cancel) {
511            return Some(StandardButton::Cancel);
512        }
513        buttons.last().copied()
514    }
515}
516
517// ── The MessageBox widget ──────────────────────────────────────────
518
519/// A modal alert dialog that displays a severity icon, title, body text, and
520/// one or more buttons.
521///
522/// Constructed via severity-named constructors ([`MessageBox::information`],
523/// [`MessageBox::warning`], [`MessageBox::critical`], [`MessageBox::question`],
524/// [`MessageBox::plain`]), configured fluently, and presented with
525/// [`MessageBox::present`]. See the module documentation for the full guide.
526pub struct MessageBox {
527    severity: MessageBoxSeverity,
528    title: LocalizedString,
529    text: Option<LocalizedString>,
530    informative_text: Option<LocalizedString>,
531    detailed_text: Option<LocalizedString>,
532    buttons_config: Option<MessageBoxButtons>,
533    extra_buttons: Vec<MessageBoxButton>,
534    default_button: Option<StandardButton>,
535    escape_button: Option<StandardButton>,
536    show_again_label: Option<LocalizedString>,
537    show_again_state: Option<Signal<bool>>,
538    on_result: Option<Box<dyn Fn(MessageBoxResult, &mut EventContext)>>,
539    default_button_id: Cell<Option<WidgetId>>,
540    root_child_id: Option<WidgetId>,
541    state: Option<Rc<State>>,
542}
543
544impl std::fmt::Debug for MessageBox {
545    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546        f.debug_struct("MessageBox")
547            .field("severity", &self.severity)
548            .field("title", &self.title)
549            .field("text", &self.text)
550            .field("informative_text", &self.informative_text)
551            .field("detailed_text", &self.detailed_text)
552            .field("default_button", &self.default_button)
553            .field("escape_button", &self.escape_button)
554            .finish()
555    }
556}
557
558impl MessageBox {
559    fn new_with_severity(severity: MessageBoxSeverity, title: impl Into<LocalizedString>) -> Self {
560        let title = title.into();
561        Self {
562            severity,
563            title,
564            text: None,
565            informative_text: None,
566            detailed_text: None,
567            buttons_config: None,
568            extra_buttons: Vec::new(),
569            default_button: None,
570            escape_button: None,
571            show_again_label: None,
572            show_again_state: None,
573            on_result: None,
574            default_button_id: Cell::new(None),
575            root_child_id: None,
576            state: None,
577        }
578    }
579
580    /// Construct an informational MessageBox (`Information` severity).
581    pub fn information(title: impl Into<LocalizedString>) -> Self {
582        Self::new_with_severity(MessageBoxSeverity::Information, title)
583    }
584
585    /// Construct a warning MessageBox (`Warning` severity).
586    pub fn warning(title: impl Into<LocalizedString>) -> Self {
587        Self::new_with_severity(MessageBoxSeverity::Warning, title)
588    }
589
590    /// Construct a critical-error MessageBox (`Critical` severity).
591    /// Click-outside dismissal is disabled; use an explicit button or
592    /// Escape to close.
593    pub fn critical(title: impl Into<LocalizedString>) -> Self {
594        Self::new_with_severity(MessageBoxSeverity::Critical, title)
595    }
596
597    /// Construct a confirmation / question MessageBox (`Question`
598    /// severity).
599    pub fn question(title: impl Into<LocalizedString>) -> Self {
600        Self::new_with_severity(MessageBoxSeverity::Question, title)
601    }
602
603    /// Construct a plain MessageBox with no severity icon.
604    pub fn plain(title: impl Into<LocalizedString>) -> Self {
605        Self::new_with_severity(MessageBoxSeverity::None, title)
606    }
607
608    /// Primary message line, rendered in `typography.body` with
609    /// `text_primary`. Prefer a short, self-contained sentence —
610    /// details belong in `informative_text`.
611    pub fn text(mut self, text: impl Into<LocalizedString>) -> Self {
612        self.text = Some(text.into());
613        self
614    }
615
616    /// Secondary, explanatory text rendered below the primary text in
617    /// `typography.body` with `text_secondary`. Matches Qt's
618    /// `setInformativeText`.
619    pub fn informative_text(mut self, text: impl Into<LocalizedString>) -> Self {
620        self.informative_text = Some(text.into());
621        self
622    }
623
624    /// Detailed text hidden behind a "Show details" [`Accordion`] —
625    /// for technical diagnostics (stack traces, error codes). Matches
626    /// Qt's `setDetailedText`.
627    pub fn detailed_text(mut self, text: impl Into<LocalizedString>) -> Self {
628        self.detailed_text = Some(text.into());
629        self
630    }
631
632    /// Apply a preset button bundle. Implicitly sets default and
633    /// escape buttons for the preset (both can be overridden via
634    /// [`MessageBox::default_button`] and
635    /// [`MessageBox::escape_button`]).
636    pub fn buttons(mut self, preset: MessageBoxButtons) -> Self {
637        if self.default_button.is_none() {
638            self.default_button = preset.preset_default();
639        }
640        if self.escape_button.is_none() {
641            self.escape_button = preset.preset_escape();
642        }
643        self.buttons_config = Some(preset);
644        self
645    }
646
647    /// Append a single button. Use to augment a preset (rare) or to
648    /// build a bespoke button row without going through
649    /// [`MessageBoxButtons::Custom`].
650    pub fn add_button(mut self, button: impl Into<MessageBoxButton>) -> Self {
651        self.extra_buttons.push(button.into());
652        self
653    }
654
655    /// Mark which button activates on Enter and receives initial
656    /// focus. Must refer to one of the buttons configured via
657    /// `buttons` / `add_button`.
658    pub fn default_button(mut self, which: StandardButton) -> Self {
659        self.default_button = Some(which);
660        self
661    }
662
663    /// Mark which button activates on Escape (and scrim-click, when
664    /// allowed). Must refer to one of the configured buttons.
665    pub fn escape_button(mut self, which: StandardButton) -> Self {
666        self.escape_button = Some(which);
667        self
668    }
669
670    /// Attach a "Don't show again"-style checkbox below the body.
671    /// Internally creates a `Signal<bool>` initialized to `false` and
672    /// reports its state in [`MessageBoxResult::checkbox_checked`].
673    /// For external observation, use
674    /// [`MessageBox::show_again_checkbox_state`] instead.
675    pub fn show_again_checkbox(mut self, label: impl Into<LocalizedString>) -> Self {
676        self.show_again_label = Some(label.into());
677        self
678    }
679
680    /// Like [`MessageBox::show_again_checkbox`], but with a
681    /// caller-owned `Signal<bool>` so the checkbox state survives the
682    /// dialog lifetime (useful for "remember my choice" persistence).
683    pub fn show_again_checkbox_state(mut self, signal: Signal<bool>) -> Self {
684        self.show_again_state = Some(signal);
685        self
686    }
687
688    /// Register the result callback, invoked exactly once when a
689    /// button fires (either by click or by Enter/Escape shortcut).
690    pub fn on_result(mut self, f: impl Fn(MessageBoxResult, &mut EventContext) + 'static) -> Self {
691        self.on_result = Some(Box::new(f));
692        self
693    }
694
695    /// Present the MessageBox as a modal on top of `ctx`'s current
696    /// tree. Consumes `self`; callers who need to present multiple
697    /// dialogs with shared config should build a factory closure.
698    pub fn present(self, ctx: &mut EventContext) {
699        let title = self.title.clone();
700        let close_behavior = if self.severity == MessageBoxSeverity::Critical {
701            ModalCloseBehavior::EscapeKey
702        } else {
703            ModalCloseBehavior::EscapeOrClickOutside
704        };
705
706        let dialog_title = self.title.clone();
707        let mut inner = Some(self);
708        ctx.present_modal(
709            ModalRequest::deferred(move |tree| {
710                let mb = inner
711                    .take()
712                    .expect("MessageBox present closure called twice");
713                tree.add(ModalContainer::new(mb).title(dialog_title.clone()))
714            })
715            .presentation(ModalPresentation::Auto)
716            .close_behavior(close_behavior)
717            .title(title)
718            .size(460, 140),
719        );
720    }
721
722    fn resolve_buttons(&mut self) -> Vec<MessageBoxButton> {
723        let mut resolved = self
724            .buttons_config
725            .clone()
726            .map(|b| b.into_buttons())
727            .unwrap_or_default();
728        resolved.extend(self.extra_buttons.iter().cloned());
729        if resolved.is_empty() {
730            resolved.push(StandardButton::Ok.into());
731            if self.default_button.is_none() {
732                self.default_button = Some(StandardButton::Ok);
733            }
734            if self.escape_button.is_none() {
735                self.escape_button = Some(StandardButton::Ok);
736            }
737        }
738        resolved
739    }
740}
741
742impl Widget for MessageBox {
743    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
744        let theme = ctx.theme().clone();
745
746        let checkbox_signal = self
747            .show_again_state
748            .clone()
749            .unwrap_or_else(|| ctx.signal(false));
750        let state = State::new(checkbox_signal.clone());
751        *state.on_result.borrow_mut() = self.on_result.take();
752
753        let buttons = self.resolve_buttons();
754        *state.buttons.borrow_mut() = buttons.iter().map(|b| b.kind).collect();
755        state.default_button.set(self.default_button);
756        state.escape_button.set(self.escape_button);
757
758        let mut header_text_stack = VStack::new().spacing(6.0);
759        header_text_stack = header_text_stack.child(
760            TextWidget::new(self.title.clone())
761                .style(theme.typography.body_bold.clone())
762                .color(theme.colors.text_primary),
763        );
764        if let Some(text) = self.text.clone() {
765            header_text_stack = header_text_stack.child(
766                TextWidget::new(text)
767                    .style(theme.typography.body.clone())
768                    .color(theme.colors.text_primary),
769            );
770        }
771        if let Some(info) = self.informative_text.clone() {
772            header_text_stack = header_text_stack.child(
773                TextWidget::new(info)
774                    .style(theme.typography.body.clone())
775                    .color(theme.colors.text_secondary),
776            );
777        }
778
779        let header: Box<dyn Widget> = if let Some(kind) = severity_icon_kind(self.severity) {
780            // Wrap the text stack in `Expand::horizontal()` so HStack
781            // distributes its width slack to the text column. Without
782            // this the HStack measures the text stack with width=None
783            // (single-line), which makes the body / informative text
784            // overflow the dialog instead of wrapping.
785            Box::new(
786                HStack::new()
787                    .spacing(16.0)
788                    .alignment(VAlignment::Top)
789                    .child(SeverityBadge::new(kind, SEVERITY_ICON_SIZE))
790                    .child(Expand::horizontal().child(header_text_stack)),
791            )
792        } else {
793            Box::new(header_text_stack)
794        };
795
796        let detailed_child: Option<Box<dyn Widget>> = self.detailed_text.clone().map(|text| {
797            let expanded = ctx.signal(false);
798            let label: LocalizedString = teksilo_i18n::tr_widget!(messagebox_show_details());
799            let body = TextWidget::new(text)
800                .style(theme.typography.small.clone())
801                .color(theme.colors.text_secondary);
802            // Capped and scrollable rather than free-growing: see `DETAILS_MAX_HEIGHT`.
803            // `preferred_height` caps the height only when the parent proposes an
804            // unconstrained one — which a hugging dialog does — while leaving the width to
805            // follow the content, so short details still size to themselves and never
806            // acquire a scroll bar they do not need.
807            let scroller = ScrollArea::new()
808                .child(body)
809                .preferred_height(DETAILS_MAX_HEIGHT);
810            let accordion: Box<dyn Widget> =
811                Box::new(Accordion::new(label, expanded).content(scroller));
812            accordion
813        });
814
815        let checkbox_child: Option<Box<dyn Widget>> = self.show_again_label.clone().map(|label| {
816            let cb: Box<dyn Widget> = Box::new(Checkbox::new(checkbox_signal.clone()).label(label));
817            cb
818        });
819
820        let mut footer = HStack::new().spacing(8.0).child(Spacer::new());
821        for button_cfg in &buttons {
822            let kind = button_cfg.kind;
823            let label = button_cfg.resolved_label();
824            let variant = if Some(kind) == self.default_button {
825                ButtonVariant::Filled
826            } else {
827                ButtonVariant::Plain
828            };
829            let state_for_btn = state.clone();
830            let btn_id = ctx.add(
831                Button::new(label)
832                    .variant(variant)
833                    .on_activate_fn(move |ctx| {
834                        state_for_btn.fire(kind, false, ctx);
835                    }),
836            );
837            if Some(kind) == self.default_button {
838                self.default_button_id.set(Some(btn_id));
839            }
840            footer = footer.add_child(btn_id);
841        }
842
843        let mut stack = VStack::new().spacing(16.0);
844        stack = stack.add_child(ctx.add_boxed(header));
845        if let Some(det) = detailed_child {
846            stack = stack.add_child(ctx.add_boxed(det));
847        }
848        if let Some(cb) = checkbox_child {
849            stack = stack.add_child(ctx.add_boxed(cb));
850        }
851        // Push the footer to the bottom of the dialog by absorbing any
852        // vertical slack with a Spacer (flex=1).
853        stack = stack.add_child(ctx.add(Spacer::new()));
854        let footer_id = ctx.add(footer);
855        stack = stack.add_child(footer_id);
856
857        let root = ctx.add(stack);
858        self.root_child_id = Some(root);
859
860        {
861            let state_enter = state.clone();
862            ctx.register_action(
863                Action::new(DEFAULT_INTENT_NAME).on_invoke(move |_intent, ctx| {
864                    if let Some(kind) = state_enter.default_button.get() {
865                        state_enter.fire(kind, false, ctx);
866                    }
867                }),
868            );
869            ctx.register_shortcut(
870                Shortcut::new(DEFAULT_INTENT_NAME)
871                    .primary(KeyStroke::new(Key::Enter, Modifiers::NONE))
872                    .build(),
873            );
874        }
875        {
876            let state_escape = state.clone();
877            ctx.register_action(
878                Action::new(ESCAPE_INTENT_NAME).on_invoke(move |_intent, ctx| {
879                    if let Some(kind) = state_escape.resolve_escape_button() {
880                        state_escape.fire(kind, true, ctx);
881                    } else {
882                        ctx.dismiss_modal();
883                    }
884                }),
885            );
886            ctx.register_shortcut(
887                Shortcut::new(ESCAPE_INTENT_NAME)
888                    .primary(KeyStroke::new(Key::Escape, Modifiers::NONE))
889                    .build(),
890            );
891        }
892
893        self.state = Some(state);
894        vec![root]
895    }
896
897    fn layout_response(
898        &self,
899        proposal: SizeProposal,
900        ctx: &LayoutContext,
901    ) -> teksilo_core::widget::LayoutResponse {
902        // Enforce a 460×140 floor without clamping the forwarded
903        // proposal: the overlay's intrinsic-measurement pass calls us
904        // with (None, None), and a Spacer-bearing VStack child would
905        // otherwise collapse to the proposal height. Letting the child
906        // see the unmodified proposal lets it report its real natural
907        // size — required so the overlay grows when the "Show details"
908        // accordion expands.
909        let child = self
910            .root_child_id
911            .and_then(|id| ctx.child_size(id, proposal))
912            .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
913        Size::new(child.width.max(460.0), child.height.max(140.0)).into()
914    }
915
916    fn place_children(
917        &self,
918        bounds: Rect,
919        _proposal: SizeProposal,
920        children: &mut [WidgetPlacement],
921        _ctx: &LayoutContext,
922    ) {
923        for child in children.iter_mut() {
924            child.origin = bounds.origin();
925            child.size = bounds.size();
926        }
927    }
928
929    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
930
931    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
932        builder.set_role(teksilo_core::accesskit::Role::AlertDialog);
933        builder.set_name(self.title.clone());
934        if let Some(description) = self.accessible_description() {
935            builder.set_description(description);
936        }
937        builder.set_modal();
938        builder.set_live(teksilo_core::accesskit::Live::Assertive);
939        builder.add_action(teksilo_core::accesskit::Action::Focus);
940    }
941
942    fn accessible_title_hint(&self) -> Option<String> {
943        Some(self.title.resolve_now())
944    }
945
946    fn initial_focus_hint(&self) -> Option<WidgetId> {
947        self.default_button_id.get()
948    }
949
950    fn children(&self) -> Vec<WidgetId> {
951        self.root_child_id.into_iter().collect()
952    }
953}
954
955impl MessageBox {
956    fn accessible_description(&self) -> Option<String> {
957        match (
958            self.text.as_ref().map(|t| t.resolve_now()),
959            self.informative_text.as_ref().map(|i| i.resolve_now()),
960        ) {
961            (None, None) => None,
962            (Some(t), None) => Some(t),
963            (None, Some(i)) => Some(i),
964            (Some(t), Some(i)) => Some(format!("{t}\n{i}")),
965        }
966    }
967}
968
969/// Extension trait on [`EventContext`] for ergonomic MessageBox
970/// presentation. Mirrors `ctx.present_modal(...)` for the general
971/// case.
972pub trait EventContextMessageBoxExt {
973    /// Present `mb` as a modal. Equivalent to `mb.present(self)`.
974    fn present_message_box(&mut self, mb: MessageBox);
975}
976
977impl EventContextMessageBoxExt for EventContext<'_> {
978    fn present_message_box(&mut self, mb: MessageBox) {
979        mb.present(self);
980    }
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986    use teksilo_core::ModalContent;
987    use teksilo_core::event::WidgetEvent;
988    use teksilo_core::widget_tree::WidgetTree;
989    use teksilo_i18n::lit;
990
991    /// Mirrors the focus resolution `teksilo_app::present_in_tree_modal_request`
992    /// applies after the modal content subtree is built. Reproduced here
993    /// because `teksilo-widgets` can't depend on `teksilo-app`.
994    fn present_and_lay_out(tree: &mut WidgetTree, mb: MessageBox) -> WidgetId {
995        use crate::button::Button as Btn;
996        let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(mb)));
997        let mb_for_closure = mb_cell.clone();
998        let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
999            if let Some(mb) = mb_for_closure.borrow_mut().take() {
1000                mb.present(ctx);
1001            }
1002        }));
1003        tree.layout(SizeProposal::exact(800.0, 600.0));
1004        tree.dispatch_event(WidgetEvent::AccessAction {
1005            action: teksilo_core::accesskit::Action::Click,
1006            target: Some(trigger),
1007            target_node: teksilo_core::accessibility::root_node_id(),
1008            data: None,
1009        });
1010        let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1011        let content_id = match request.content {
1012            ModalContent::Deferred(builder) => builder(tree),
1013            ModalContent::ExistingWidget(_) => panic!("MessageBox must use deferred content"),
1014        };
1015        tree.layout(SizeProposal::exact(800.0, 600.0));
1016        let focus_target = request
1017            .focus_target
1018            .filter(|id| tree.is_active(*id) && tree.is_descendant_of(*id, content_id))
1019            .or_else(|| tree.widget_initial_focus_hint(content_id))
1020            .or_else(|| tree.first_focusable_descendant(content_id));
1021        if let Some(id) = focus_target {
1022            tree.focus(id);
1023        }
1024        content_id
1025    }
1026
1027    #[test]
1028    fn present_queues_modal_request() {
1029        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1030        let mb = MessageBox::information(lit!("t"))
1031            .text(lit!("x"))
1032            .buttons(MessageBoxButtons::Ok);
1033        let _content = present_and_lay_out(&mut tree, mb);
1034        assert!(tree.find_by_label("t").is_some());
1035    }
1036
1037    #[test]
1038    fn critical_uses_escape_only_close_behavior() {
1039        use crate::button::Button as Btn;
1040        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1041        let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(
1042            MessageBox::critical(lit!("Fatal"))
1043                .text(lit!("Boom"))
1044                .buttons(MessageBoxButtons::Ok),
1045        )));
1046        let mb_for_closure = mb_cell.clone();
1047        let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
1048            if let Some(mb) = mb_for_closure.borrow_mut().take() {
1049                mb.present(ctx);
1050            }
1051        }));
1052        tree.layout(SizeProposal::exact(800.0, 600.0));
1053        tree.dispatch_event(WidgetEvent::AccessAction {
1054            action: teksilo_core::accesskit::Action::Click,
1055            target: Some(trigger),
1056            target_node: teksilo_core::accessibility::root_node_id(),
1057            data: None,
1058        });
1059        let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1060        assert_eq!(request.close_behavior, ModalCloseBehavior::EscapeKey);
1061    }
1062
1063    #[test]
1064    fn alert_dialog_role_exposed() {
1065        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1066        let mb = MessageBox::warning(lit!("Title"))
1067            .text(lit!("Body"))
1068            .buttons(MessageBoxButtons::Ok);
1069        let content = present_and_lay_out(&mut tree, mb);
1070        // `content` is the `ModalContainer`; its child is now the
1071        // `DialogStyle` panel frame, and the `MessageBox` sits one
1072        // level below that.
1073        let panel = tree.children(content).first().copied().unwrap();
1074        let mb_id = tree.children(panel).first().copied().unwrap();
1075        let info = tree.accessibility_node(mb_id);
1076        assert_eq!(info.role(), teksilo_core::accesskit::Role::AlertDialog);
1077        assert_eq!(info.name(), Some("Title"));
1078    }
1079
1080    #[test]
1081    fn ok_button_fires_result_with_correct_kind() {
1082        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1083        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1084        let captured_for_handler = captured.clone();
1085        let mb = MessageBox::information(lit!("t"))
1086            .text(lit!("x"))
1087            .buttons(MessageBoxButtons::Ok)
1088            .on_result(move |r, _ctx| {
1089                *captured_for_handler.borrow_mut() = Some(r);
1090            });
1091        let _content = present_and_lay_out(&mut tree, mb);
1092        let ok_id = tree
1093            .find_by_label(&StandardButton::Ok.default_label().resolve_now())
1094            .unwrap();
1095        tree.dispatch_event(WidgetEvent::AccessAction {
1096            action: teksilo_core::accesskit::Action::Click,
1097            target: Some(ok_id),
1098            target_node: teksilo_core::accessibility::root_node_id(),
1099            data: None,
1100        });
1101        let result = captured.borrow().expect("result must be captured");
1102        assert_eq!(result.button, StandardButton::Ok);
1103        assert!(!result.checkbox_checked);
1104        assert!(!result.dismissed_by_escape);
1105    }
1106
1107    #[test]
1108    fn default_button_is_focused_on_open() {
1109        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1110        let mb = MessageBox::question(lit!("t"))
1111            .text(lit!("x"))
1112            .buttons(MessageBoxButtons::YesNoCancel)
1113            .default_button(StandardButton::No);
1114        let _content = present_and_lay_out(&mut tree, mb);
1115        let no_id = tree
1116            .find_by_label(&StandardButton::No.default_label().resolve_now())
1117            .unwrap();
1118        assert_eq!(tree.focused(), Some(no_id));
1119    }
1120
1121    /// The reason this preset default exists. A Yes/No box is a confirmation,
1122    /// a confirmation usually precedes something destructive, and Enter is what
1123    /// a keyboard user presses on a dialog they have not finished reading.
1124    ///
1125    /// Focus placement is the whole contract: `Node::keyboard_shortcut` appears
1126    /// in none of the three AccessKit adapters, so no platform announces which
1127    /// button is the default. A screen-reader user finds out by triggering it.
1128    #[test]
1129    fn a_yes_no_box_defaults_to_no_so_enter_cannot_destroy() {
1130        for buttons in [MessageBoxButtons::YesNo, MessageBoxButtons::YesNoCancel] {
1131            let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1132            let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1133            let captured_for_handler = captured.clone();
1134            let mb = MessageBox::question(lit!("t"))
1135                .text(lit!("x"))
1136                .buttons(buttons.clone())
1137                .on_result(move |r, _ctx| {
1138                    *captured_for_handler.borrow_mut() = Some(r);
1139                });
1140            let _content = present_and_lay_out(&mut tree, mb);
1141
1142            let no_id = tree
1143                .find_by_label(&StandardButton::No.default_label().resolve_now())
1144                .unwrap();
1145            assert_eq!(
1146                tree.focused(),
1147                Some(no_id),
1148                "{buttons:?} must open with No focused"
1149            );
1150
1151            tree.press_key(Key::Enter, Modifiers::NONE);
1152            let result = captured.borrow().expect("result must be captured");
1153            assert_eq!(
1154                result.button,
1155                StandardButton::No,
1156                "{buttons:?}: Enter on an unread confirmation must not answer Yes"
1157            );
1158        }
1159    }
1160
1161    /// Escape and Enter are different buttons for `YesNoCancel`, and both
1162    /// matter: Escape means "I did not mean to be in this dialog", which is
1163    /// Cancel, while Enter answers the question that was asked.
1164    #[test]
1165    fn yes_no_cancel_keeps_cancel_as_the_escape_button() {
1166        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1167        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1168        let captured_for_handler = captured.clone();
1169        let mb = MessageBox::question(lit!("t"))
1170            .text(lit!("x"))
1171            .buttons(MessageBoxButtons::YesNoCancel)
1172            .on_result(move |r, _ctx| {
1173                *captured_for_handler.borrow_mut() = Some(r);
1174            });
1175        let _content = present_and_lay_out(&mut tree, mb);
1176        tree.press_key(Key::Escape, Modifiers::NONE);
1177        let result = captured.borrow().expect("result must be captured");
1178        assert_eq!(result.button, StandardButton::Cancel);
1179        assert!(result.dismissed_by_escape);
1180    }
1181
1182    /// The override still works, and is how a box that asks something safe
1183    /// gets Yes back.
1184    #[test]
1185    fn default_button_overrides_the_preset() {
1186        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1187        let mb = MessageBox::question(lit!("t"))
1188            .text(lit!("x"))
1189            .buttons(MessageBoxButtons::YesNo)
1190            .default_button(StandardButton::Yes);
1191        let _content = present_and_lay_out(&mut tree, mb);
1192        let yes_id = tree
1193            .find_by_label(&StandardButton::Yes.default_label().resolve_now())
1194            .unwrap();
1195        assert_eq!(tree.focused(), Some(yes_id));
1196    }
1197
1198    #[test]
1199    fn enter_fires_default_button_from_any_focus() {
1200        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1201        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1202        let captured_for_handler = captured.clone();
1203        let mb = MessageBox::question(lit!("t"))
1204            .text(lit!("x"))
1205            .buttons(MessageBoxButtons::OkCancel)
1206            .on_result(move |r, _ctx| {
1207                *captured_for_handler.borrow_mut() = Some(r);
1208            });
1209        let _content = present_and_lay_out(&mut tree, mb);
1210        let cancel_id = tree
1211            .find_by_label(&StandardButton::Cancel.default_label().resolve_now())
1212            .unwrap();
1213        tree.focus(cancel_id);
1214        tree.press_key(Key::Enter, Modifiers::NONE);
1215        let result = captured.borrow().expect("result must be captured");
1216        assert_eq!(result.button, StandardButton::Ok);
1217        assert!(!result.dismissed_by_escape);
1218    }
1219
1220    #[test]
1221    fn escape_fires_escape_button_and_marks_dismissed() {
1222        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1223        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1224        let captured_for_handler = captured.clone();
1225        let mb = MessageBox::question(lit!("t"))
1226            .text(lit!("x"))
1227            .buttons(MessageBoxButtons::YesNoCancel)
1228            .on_result(move |r, _ctx| {
1229                *captured_for_handler.borrow_mut() = Some(r);
1230            });
1231        let _content = present_and_lay_out(&mut tree, mb);
1232        tree.press_key(Key::Escape, Modifiers::NONE);
1233        let result = captured.borrow().expect("result must be captured");
1234        assert_eq!(result.button, StandardButton::Cancel);
1235        assert!(result.dismissed_by_escape);
1236    }
1237
1238    #[test]
1239    fn checkbox_state_reported_in_result() {
1240        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1241        let shared_state = Signal::new(false);
1242        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1243        let captured_for_handler = captured.clone();
1244        let mb = MessageBox::information(lit!("t"))
1245            .text(lit!("x"))
1246            .buttons(MessageBoxButtons::Ok)
1247            .show_again_checkbox_state(shared_state.clone())
1248            .show_again_checkbox(lit!("Don't show again"))
1249            .on_result(move |r, _ctx| {
1250                *captured_for_handler.borrow_mut() = Some(r);
1251            });
1252        let _content = present_and_lay_out(&mut tree, mb);
1253        shared_state.set(true);
1254        let ok_id = tree
1255            .find_by_label(&StandardButton::Ok.default_label().resolve_now())
1256            .unwrap();
1257        tree.dispatch_event(WidgetEvent::AccessAction {
1258            action: teksilo_core::accesskit::Action::Click,
1259            target: Some(ok_id),
1260            target_node: teksilo_core::accessibility::root_node_id(),
1261            data: None,
1262        });
1263        assert!(captured.borrow().unwrap().checkbox_checked);
1264    }
1265
1266    #[test]
1267    fn accessible_title_hint_propagates_to_container() {
1268        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1269        let mb = MessageBox::information(lit!("Title propagation test"))
1270            .text(lit!("Body"))
1271            .buttons(MessageBoxButtons::Ok);
1272        let content = present_and_lay_out(&mut tree, mb);
1273        let info = tree.accessibility_node(content);
1274        assert_eq!(info.role(), teksilo_core::accesskit::Role::Dialog);
1275        assert_eq!(info.name(), Some("Title propagation test"));
1276    }
1277
1278    #[test]
1279    fn standard_button_roles_classify_correctly() {
1280        assert_eq!(StandardButton::Ok.role(), ButtonRole::Accept);
1281        assert_eq!(StandardButton::Yes.role(), ButtonRole::Accept);
1282        assert_eq!(StandardButton::Save.role(), ButtonRole::Accept);
1283        assert_eq!(StandardButton::Cancel.role(), ButtonRole::Reject);
1284        assert_eq!(StandardButton::No.role(), ButtonRole::Reject);
1285        assert_eq!(StandardButton::Abort.role(), ButtonRole::Reject);
1286        assert_eq!(StandardButton::Discard.role(), ButtonRole::Destructive);
1287        assert_eq!(StandardButton::Help.role(), ButtonRole::Action);
1288        assert_eq!(StandardButton::Ignore.role(), ButtonRole::Action);
1289    }
1290
1291    /// A long `detailed_text` expands, stays reachable, and lays out without blowing up.
1292    ///
1293    /// The pane is where callers put the things with no length bound — a stack trace, an OS
1294    /// error dump, a list of every affected row — and it used to be a bare `TextWidget` in
1295    /// the accordion, so expanding it grew the dialog until the button row went off the
1296    /// bottom of the screen. It is capped and scrollable now (`DETAILS_MAX_HEIGHT`).
1297    ///
1298    /// This pins the reachable half: a hundred lines of detail still build, expand and
1299    /// re-lay out with the buttons intact. The *height* cap itself is not asserted here —
1300    /// `WidgetTree` exposes no laid-out geometry to widget tests, so there is nothing to
1301    /// measure against; that half is the `ScrollArea::preferred_height` contract, which
1302    /// `scroll_area` owns and tests.
1303    #[test]
1304    fn a_long_details_pane_expands_and_keeps_the_dialog_intact() {
1305        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1306        let long: String = (1..=100)
1307            .map(|i| format!("line {i} of a very long detail dump\n"))
1308            .collect();
1309        let mb = MessageBox::critical(lit!("Could not open file"))
1310            .text(lit!("It went wrong."))
1311            .detailed_text(lit!(long))
1312            .buttons(MessageBoxButtons::Ok);
1313        let _content = present_and_lay_out(&mut tree, mb);
1314
1315        let toggle = tree
1316            .find_by_label(&teksilo_i18n::tr_widget!(messagebox_show_details()).resolve_now())
1317            .expect("the Show details toggle");
1318        tree.dispatch_event(WidgetEvent::AccessAction {
1319            action: teksilo_core::accesskit::Action::Click,
1320            target: Some(toggle),
1321            target_node: teksilo_core::accessibility::root_node_id(),
1322            data: None,
1323        });
1324        tree.layout(SizeProposal::exact(800.0, 600.0));
1325
1326        // The dialog is still whole: its title and its button both survived the expansion.
1327        assert!(tree.find_by_label("Could not open file").is_some());
1328        assert!(
1329            tree.find_by_label(&StandardButton::Ok.default_label().resolve_now())
1330                .is_some(),
1331            "the button row must survive an expanded details pane"
1332        );
1333    }
1334
1335    #[test]
1336    fn escape_resolution_prefers_explicit_escape_button() {
1337        let state = State::new(Signal::new(false));
1338        *state.buttons.borrow_mut() = vec![StandardButton::Save, StandardButton::Discard];
1339        state.escape_button.set(Some(StandardButton::Discard));
1340        assert_eq!(state.resolve_escape_button(), Some(StandardButton::Discard));
1341    }
1342
1343    #[test]
1344    fn escape_resolution_falls_back_to_first_reject() {
1345        let state = State::new(Signal::new(false));
1346        *state.buttons.borrow_mut() = vec![
1347            StandardButton::Retry,
1348            StandardButton::Ignore,
1349            StandardButton::Abort,
1350        ];
1351        state.escape_button.set(None);
1352        assert_eq!(state.resolve_escape_button(), Some(StandardButton::Abort));
1353    }
1354
1355    #[test]
1356    fn escape_resolution_falls_back_to_last_when_no_reject() {
1357        let state = State::new(Signal::new(false));
1358        *state.buttons.borrow_mut() = vec![StandardButton::Ok, StandardButton::Help];
1359        state.escape_button.set(None);
1360        assert_eq!(state.resolve_escape_button(), Some(StandardButton::Help));
1361    }
1362}