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