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    /// Every button this box built, by widget id. Read by the Enter
471    /// shortcut so it can answer for the button the user is standing on
472    /// rather than for the default. Rebuilt on every `build`.
473    button_ids: RefCell<Vec<(WidgetId, StandardButton)>>,
474    /// Guards against multiple result-callback invocations when a
475    /// button click races the Escape shortcut.
476    fired: Cell<bool>,
477}
478
479impl State {
480    fn new(checkbox: Signal<bool>) -> Rc<Self> {
481        Rc::new(Self {
482            on_result: RefCell::new(None),
483            checkbox,
484            escape_button: Cell::new(None),
485            default_button: Cell::new(None),
486            buttons: RefCell::new(Vec::new()),
487            button_ids: RefCell::new(Vec::new()),
488            fired: Cell::new(false),
489        })
490    }
491
492    /// Which of this box's buttons a widget id is, if it is one of them.
493    fn button_for(&self, id: WidgetId) -> Option<StandardButton> {
494        self.button_ids
495            .borrow()
496            .iter()
497            .find(|(btn, _)| *btn == id)
498            .map(|(_, kind)| *kind)
499    }
500
501    fn fire(&self, button: StandardButton, by_escape: bool, ctx: &mut EventContext) {
502        if self.fired.replace(true) {
503            return;
504        }
505        let result = MessageBoxResult {
506            button,
507            checkbox_checked: self.checkbox.get(),
508            dismissed_by_escape: by_escape,
509        };
510        if let Some(handler) = self.on_result.borrow().as_ref() {
511            handler(result, ctx);
512        }
513        ctx.dismiss_modal();
514    }
515
516    fn resolve_escape_button(&self) -> Option<StandardButton> {
517        if let Some(btn) = self.escape_button.get() {
518            return Some(btn);
519        }
520        let buttons = self.buttons.borrow();
521        if let Some(btn) = buttons.iter().find(|b| b.role() == ButtonRole::Reject) {
522            return Some(*btn);
523        }
524        if buttons.contains(&StandardButton::Cancel) {
525            return Some(StandardButton::Cancel);
526        }
527        buttons.last().copied()
528    }
529}
530
531// ── The MessageBox widget ──────────────────────────────────────────
532
533/// A modal alert dialog that displays a severity icon, title, body text, and
534/// one or more buttons.
535///
536/// Constructed via severity-named constructors ([`MessageBox::information`],
537/// [`MessageBox::warning`], [`MessageBox::critical`], [`MessageBox::question`],
538/// [`MessageBox::plain`]), configured fluently, and presented with
539/// [`MessageBox::present`]. See the module documentation for the full guide.
540pub struct MessageBox {
541    severity: MessageBoxSeverity,
542    title: LocalizedString,
543    text: Option<LocalizedString>,
544    informative_text: Option<LocalizedString>,
545    detailed_text: Option<LocalizedString>,
546    buttons_config: Option<MessageBoxButtons>,
547    extra_buttons: Vec<MessageBoxButton>,
548    default_button: Option<StandardButton>,
549    escape_button: Option<StandardButton>,
550    show_again_label: Option<LocalizedString>,
551    show_again_state: Option<Signal<bool>>,
552    on_result: Option<Box<dyn Fn(MessageBoxResult, &mut EventContext)>>,
553    default_button_id: Cell<Option<WidgetId>>,
554    root_child_id: Option<WidgetId>,
555    state: Option<Rc<State>>,
556}
557
558impl std::fmt::Debug for MessageBox {
559    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560        f.debug_struct("MessageBox")
561            .field("severity", &self.severity)
562            .field("title", &self.title)
563            .field("text", &self.text)
564            .field("informative_text", &self.informative_text)
565            .field("detailed_text", &self.detailed_text)
566            .field("default_button", &self.default_button)
567            .field("escape_button", &self.escape_button)
568            .finish()
569    }
570}
571
572impl MessageBox {
573    fn new_with_severity(severity: MessageBoxSeverity, title: impl Into<LocalizedString>) -> Self {
574        let title = title.into();
575        Self {
576            severity,
577            title,
578            text: None,
579            informative_text: None,
580            detailed_text: None,
581            buttons_config: None,
582            extra_buttons: Vec::new(),
583            default_button: None,
584            escape_button: None,
585            show_again_label: None,
586            show_again_state: None,
587            on_result: None,
588            default_button_id: Cell::new(None),
589            root_child_id: None,
590            state: None,
591        }
592    }
593
594    /// Construct an informational MessageBox (`Information` severity).
595    pub fn information(title: impl Into<LocalizedString>) -> Self {
596        Self::new_with_severity(MessageBoxSeverity::Information, title)
597    }
598
599    /// Construct a warning MessageBox (`Warning` severity).
600    pub fn warning(title: impl Into<LocalizedString>) -> Self {
601        Self::new_with_severity(MessageBoxSeverity::Warning, title)
602    }
603
604    /// Construct a critical-error MessageBox (`Critical` severity).
605    /// Click-outside dismissal is disabled; use an explicit button or
606    /// Escape to close.
607    pub fn critical(title: impl Into<LocalizedString>) -> Self {
608        Self::new_with_severity(MessageBoxSeverity::Critical, title)
609    }
610
611    /// Construct a confirmation / question MessageBox (`Question`
612    /// severity).
613    pub fn question(title: impl Into<LocalizedString>) -> Self {
614        Self::new_with_severity(MessageBoxSeverity::Question, title)
615    }
616
617    /// Construct a plain MessageBox with no severity icon.
618    pub fn plain(title: impl Into<LocalizedString>) -> Self {
619        Self::new_with_severity(MessageBoxSeverity::None, title)
620    }
621
622    /// Primary message line, rendered in `typography.body` with
623    /// `text_primary`. Prefer a short, self-contained sentence —
624    /// details belong in `informative_text`.
625    pub fn text(mut self, text: impl Into<LocalizedString>) -> Self {
626        self.text = Some(text.into());
627        self
628    }
629
630    /// Secondary, explanatory text rendered below the primary text in
631    /// `typography.body` with `text_secondary`. Matches Qt's
632    /// `setInformativeText`.
633    pub fn informative_text(mut self, text: impl Into<LocalizedString>) -> Self {
634        self.informative_text = Some(text.into());
635        self
636    }
637
638    /// Detailed text hidden behind a "Show details" [`Accordion`] —
639    /// for technical diagnostics (stack traces, error codes). Matches
640    /// Qt's `setDetailedText`.
641    pub fn detailed_text(mut self, text: impl Into<LocalizedString>) -> Self {
642        self.detailed_text = Some(text.into());
643        self
644    }
645
646    /// Apply a preset button bundle. Implicitly sets default and
647    /// escape buttons for the preset (both can be overridden via
648    /// [`MessageBox::default_button`] and
649    /// [`MessageBox::escape_button`]).
650    pub fn buttons(mut self, preset: MessageBoxButtons) -> Self {
651        if self.default_button.is_none() {
652            self.default_button = preset.preset_default();
653        }
654        if self.escape_button.is_none() {
655            self.escape_button = preset.preset_escape();
656        }
657        self.buttons_config = Some(preset);
658        self
659    }
660
661    /// Append a single button. Use to augment a preset (rare) or to
662    /// build a bespoke button row without going through
663    /// [`MessageBoxButtons::Custom`].
664    pub fn add_button(mut self, button: impl Into<MessageBoxButton>) -> Self {
665        self.extra_buttons.push(button.into());
666        self
667    }
668
669    /// Mark which button activates on Enter and receives initial
670    /// focus. Must refer to one of the buttons configured via
671    /// `buttons` / `add_button`.
672    pub fn default_button(mut self, which: StandardButton) -> Self {
673        self.default_button = Some(which);
674        self
675    }
676
677    /// Mark which button activates on Escape (and scrim-click, when
678    /// allowed). Must refer to one of the configured buttons.
679    pub fn escape_button(mut self, which: StandardButton) -> Self {
680        self.escape_button = Some(which);
681        self
682    }
683
684    /// Attach a "Don't show again"-style checkbox below the body.
685    /// Internally creates a `Signal<bool>` initialized to `false` and
686    /// reports its state in [`MessageBoxResult::checkbox_checked`].
687    /// For external observation, use
688    /// [`MessageBox::show_again_checkbox_state`] instead.
689    pub fn show_again_checkbox(mut self, label: impl Into<LocalizedString>) -> Self {
690        self.show_again_label = Some(label.into());
691        self
692    }
693
694    /// Like [`MessageBox::show_again_checkbox`], but with a
695    /// caller-owned `Signal<bool>` so the checkbox state survives the
696    /// dialog lifetime (useful for "remember my choice" persistence).
697    pub fn show_again_checkbox_state(mut self, signal: Signal<bool>) -> Self {
698        self.show_again_state = Some(signal);
699        self
700    }
701
702    /// Register the result callback, invoked exactly once when a
703    /// button fires (either by click or by Enter/Escape shortcut).
704    pub fn on_result(mut self, f: impl Fn(MessageBoxResult, &mut EventContext) + 'static) -> Self {
705        self.on_result = Some(Box::new(f));
706        self
707    }
708
709    /// Present the MessageBox as a modal on top of `ctx`'s current
710    /// tree. Consumes `self`; callers who need to present multiple
711    /// dialogs with shared config should build a factory closure.
712    pub fn present(self, ctx: &mut EventContext) {
713        let title = self.title.clone();
714        let close_behavior = if self.severity == MessageBoxSeverity::Critical {
715            ModalCloseBehavior::EscapeKey
716        } else {
717            ModalCloseBehavior::EscapeOrClickOutside
718        };
719
720        let dialog_title = self.title.clone();
721        let mut inner = Some(self);
722        ctx.present_modal(
723            ModalRequest::deferred(move |tree| {
724                let mb = inner
725                    .take()
726                    .expect("MessageBox present closure called twice");
727                tree.add(ModalContainer::new(mb).title(dialog_title.clone()))
728            })
729            .presentation(ModalPresentation::Auto)
730            .close_behavior(close_behavior)
731            .title(title)
732            .size(460, 140),
733        );
734    }
735
736    fn resolve_buttons(&mut self) -> Vec<MessageBoxButton> {
737        let mut resolved = self
738            .buttons_config
739            .clone()
740            .map(|b| b.into_buttons())
741            .unwrap_or_default();
742        resolved.extend(self.extra_buttons.iter().cloned());
743        if resolved.is_empty() {
744            resolved.push(StandardButton::Ok.into());
745            if self.default_button.is_none() {
746                self.default_button = Some(StandardButton::Ok);
747            }
748            if self.escape_button.is_none() {
749                self.escape_button = Some(StandardButton::Ok);
750            }
751        }
752        resolved
753    }
754}
755
756impl Widget for MessageBox {
757    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
758        let theme = ctx.theme().clone();
759
760        let checkbox_signal = self
761            .show_again_state
762            .clone()
763            .unwrap_or_else(|| ctx.signal(false));
764        let state = State::new(checkbox_signal.clone());
765        *state.on_result.borrow_mut() = self.on_result.take();
766
767        let buttons = self.resolve_buttons();
768        *state.buttons.borrow_mut() = buttons.iter().map(|b| b.kind).collect();
769        state.default_button.set(self.default_button);
770        state.escape_button.set(self.escape_button);
771
772        let mut header_text_stack = VStack::new().spacing(6.0);
773        header_text_stack = header_text_stack.child(
774            TextWidget::new(self.title.clone())
775                .style(theme.typography.body_bold.clone())
776                .color(theme.colors.text_primary),
777        );
778        if let Some(text) = self.text.clone() {
779            header_text_stack = header_text_stack.child(
780                TextWidget::new(text)
781                    .style(theme.typography.body.clone())
782                    .color(theme.colors.text_primary),
783            );
784        }
785        if let Some(info) = self.informative_text.clone() {
786            header_text_stack = header_text_stack.child(
787                TextWidget::new(info)
788                    .style(theme.typography.body.clone())
789                    .color(theme.colors.text_secondary),
790            );
791        }
792
793        let header: Box<dyn Widget> = if let Some(kind) = severity_icon_kind(self.severity) {
794            // Wrap the text stack in `Expand::horizontal()` so HStack
795            // distributes its width slack to the text column. Without
796            // this the HStack measures the text stack with width=None
797            // (single-line), which makes the body / informative text
798            // overflow the dialog instead of wrapping.
799            Box::new(
800                HStack::new()
801                    .spacing(16.0)
802                    .alignment(VAlignment::Top)
803                    .child(SeverityBadge::new(kind, SEVERITY_ICON_SIZE))
804                    .child(Expand::horizontal().child(header_text_stack)),
805            )
806        } else {
807            Box::new(header_text_stack)
808        };
809
810        let detailed_child: Option<Box<dyn Widget>> = self.detailed_text.clone().map(|text| {
811            let expanded = ctx.signal(false);
812            let label: LocalizedString = teksilo_i18n::tr_widget!(messagebox_show_details());
813            let body = TextWidget::new(text)
814                .style(theme.typography.small.clone())
815                .color(theme.colors.text_secondary);
816            // Capped and scrollable rather than free-growing: see `DETAILS_MAX_HEIGHT`.
817            // `preferred_height` caps the height only when the parent proposes an
818            // unconstrained one — which a hugging dialog does — while leaving the width to
819            // follow the content, so short details still size to themselves and never
820            // acquire a scroll bar they do not need.
821            let scroller = ScrollArea::new()
822                .child(body)
823                .preferred_height(DETAILS_MAX_HEIGHT);
824            let accordion: Box<dyn Widget> =
825                Box::new(Accordion::new(label, expanded).content(scroller));
826            accordion
827        });
828
829        let checkbox_child: Option<Box<dyn Widget>> = self.show_again_label.clone().map(|label| {
830            let cb: Box<dyn Widget> = Box::new(Checkbox::new(checkbox_signal.clone()).label(label));
831            cb
832        });
833
834        let mut footer = HStack::new().spacing(8.0).child(Spacer::new());
835        state.button_ids.borrow_mut().clear();
836        for button_cfg in &buttons {
837            let kind = button_cfg.kind;
838            let label = button_cfg.resolved_label();
839            let variant = if Some(kind) == self.default_button {
840                ButtonVariant::Filled
841            } else {
842                ButtonVariant::Plain
843            };
844            let state_for_btn = state.clone();
845            let btn_id = ctx.add(
846                Button::new(label)
847                    .variant(variant)
848                    .on_activate_fn(move |ctx| {
849                        state_for_btn.fire(kind, false, ctx);
850                    }),
851            );
852            if Some(kind) == self.default_button {
853                self.default_button_id.set(Some(btn_id));
854            }
855            state.button_ids.borrow_mut().push((btn_id, kind));
856            footer = footer.add_child(btn_id);
857        }
858
859        let mut stack = VStack::new().spacing(16.0);
860        stack = stack.add_child(ctx.add_boxed(header));
861        if let Some(det) = detailed_child {
862            stack = stack.add_child(ctx.add_boxed(det));
863        }
864        if let Some(cb) = checkbox_child {
865            stack = stack.add_child(ctx.add_boxed(cb));
866        }
867        // Push the footer to the bottom of the dialog by absorbing any
868        // vertical slack with a Spacer (flex=1).
869        stack = stack.add_child(ctx.add(Spacer::new()));
870        let footer_id = ctx.add(footer);
871        stack = stack.add_child(footer_id);
872
873        let root = ctx.add(stack);
874        self.root_child_id = Some(root);
875
876        {
877            let state_enter = state.clone();
878            ctx.register_action(
879                Action::new(DEFAULT_INTENT_NAME).on_invoke(move |_intent, ctx| {
880                    // The focused button wins, and only then the default.
881                    //
882                    // This shortcut is widget-scoped, and a scoped shortcut is
883                    // matched before the focused widget is offered the key, so
884                    // without this the default answered for a button the user
885                    // had deliberately tabbed to: standing on Yes and pressing
886                    // Enter in a `YesNo` box whose default is No said No, shut
887                    // the dialog, and left the caller's `on_result` looking as
888                    // though the user had declined. Every desktop toolkit puts
889                    // the focused push button first here, and a confirmation
890                    // whose safe answer is the default is exactly where the
891                    // difference is destructive.
892                    //
893                    // `Button` already activates on Enter itself, so this is
894                    // only about which of the two answers the key, never about
895                    // making the button work.
896                    let focused = ctx.focused().and_then(|id| state_enter.button_for(id));
897                    if let Some(kind) = focused.or_else(|| state_enter.default_button.get()) {
898                        state_enter.fire(kind, false, ctx);
899                    }
900                }),
901            );
902            ctx.register_shortcut(
903                Shortcut::new(DEFAULT_INTENT_NAME)
904                    .primary(KeyStroke::new(Key::Enter, Modifiers::NONE))
905                    .build(),
906            );
907        }
908        {
909            let state_escape = state.clone();
910            ctx.register_action(
911                Action::new(ESCAPE_INTENT_NAME).on_invoke(move |_intent, ctx| {
912                    if let Some(kind) = state_escape.resolve_escape_button() {
913                        state_escape.fire(kind, true, ctx);
914                    } else {
915                        ctx.dismiss_modal();
916                    }
917                }),
918            );
919            ctx.register_shortcut(
920                Shortcut::new(ESCAPE_INTENT_NAME)
921                    .primary(KeyStroke::new(Key::Escape, Modifiers::NONE))
922                    .build(),
923            );
924        }
925
926        self.state = Some(state);
927        vec![root]
928    }
929
930    fn layout_response(
931        &self,
932        proposal: SizeProposal,
933        ctx: &LayoutContext,
934    ) -> teksilo_core::widget::LayoutResponse {
935        // Enforce a 460×140 floor without clamping the forwarded
936        // proposal: the overlay's intrinsic-measurement pass calls us
937        // with (None, None), and a Spacer-bearing VStack child would
938        // otherwise collapse to the proposal height. Letting the child
939        // see the unmodified proposal lets it report its real natural
940        // size — required so the overlay grows when the "Show details"
941        // accordion expands.
942        let child = self
943            .root_child_id
944            .and_then(|id| ctx.child_size(id, proposal))
945            .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
946        Size::new(child.width.max(460.0), child.height.max(140.0)).into()
947    }
948
949    fn place_children(
950        &self,
951        bounds: Rect,
952        _proposal: SizeProposal,
953        children: &mut [WidgetPlacement],
954        _ctx: &LayoutContext,
955    ) {
956        for child in children.iter_mut() {
957            child.origin = bounds.origin();
958            child.size = bounds.size();
959        }
960    }
961
962    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
963
964    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
965        builder.set_role(teksilo_core::accesskit::Role::AlertDialog);
966        builder.set_name(self.title.clone());
967        if let Some(description) = self.accessible_description() {
968            builder.set_description(description);
969        }
970        builder.set_modal();
971        builder.set_live(teksilo_core::accesskit::Live::Assertive);
972        builder.add_action(teksilo_core::accesskit::Action::Focus);
973    }
974
975    fn accessible_title_hint(&self) -> Option<String> {
976        Some(self.title.resolve_now())
977    }
978
979    fn initial_focus_hint(&self) -> Option<WidgetId> {
980        self.default_button_id.get()
981    }
982
983    fn children(&self) -> Vec<WidgetId> {
984        self.root_child_id.into_iter().collect()
985    }
986}
987
988impl MessageBox {
989    fn accessible_description(&self) -> Option<String> {
990        match (
991            self.text.as_ref().map(|t| t.resolve_now()),
992            self.informative_text.as_ref().map(|i| i.resolve_now()),
993        ) {
994            (None, None) => None,
995            (Some(t), None) => Some(t),
996            (None, Some(i)) => Some(i),
997            (Some(t), Some(i)) => Some(format!("{t}\n{i}")),
998        }
999    }
1000}
1001
1002/// Extension trait on [`EventContext`] for ergonomic MessageBox
1003/// presentation. Mirrors `ctx.present_modal(...)` for the general
1004/// case.
1005pub trait EventContextMessageBoxExt {
1006    /// Present `mb` as a modal. Equivalent to `mb.present(self)`.
1007    fn present_message_box(&mut self, mb: MessageBox);
1008}
1009
1010impl EventContextMessageBoxExt for EventContext<'_> {
1011    fn present_message_box(&mut self, mb: MessageBox) {
1012        mb.present(self);
1013    }
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::*;
1019    use teksilo_core::ModalContent;
1020    use teksilo_core::event::WidgetEvent;
1021    use teksilo_core::widget_tree::WidgetTree;
1022    use teksilo_i18n::lit;
1023
1024    /// Mirrors the focus resolution `teksilo_app::present_in_tree_modal_request`
1025    /// applies after the modal content subtree is built. Reproduced here
1026    /// because `teksilo-widgets` can't depend on `teksilo-app`.
1027    fn present_and_lay_out(tree: &mut WidgetTree, mb: MessageBox) -> WidgetId {
1028        use crate::button::Button as Btn;
1029        let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(mb)));
1030        let mb_for_closure = mb_cell.clone();
1031        let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
1032            if let Some(mb) = mb_for_closure.borrow_mut().take() {
1033                mb.present(ctx);
1034            }
1035        }));
1036        tree.layout(SizeProposal::exact(800.0, 600.0));
1037        tree.dispatch_event(WidgetEvent::AccessAction {
1038            action: teksilo_core::accesskit::Action::Click,
1039            target: Some(trigger),
1040            target_node: teksilo_core::accessibility::root_node_id(),
1041            data: None,
1042        });
1043        let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1044        let content_id = match request.content {
1045            ModalContent::Deferred(builder) => builder(tree),
1046            ModalContent::ExistingWidget(_) => panic!("MessageBox must use deferred content"),
1047        };
1048        tree.layout(SizeProposal::exact(800.0, 600.0));
1049        let focus_target = request
1050            .focus_target
1051            .filter(|id| tree.is_active(*id) && tree.is_descendant_of(*id, content_id))
1052            .or_else(|| tree.widget_initial_focus_hint(content_id))
1053            .or_else(|| tree.first_focusable_descendant(content_id));
1054        if let Some(id) = focus_target {
1055            tree.focus(id);
1056        }
1057        content_id
1058    }
1059
1060    #[test]
1061    fn present_queues_modal_request() {
1062        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1063        let mb = MessageBox::information(lit!("t"))
1064            .text(lit!("x"))
1065            .buttons(MessageBoxButtons::Ok);
1066        let _content = present_and_lay_out(&mut tree, mb);
1067        assert!(tree.find_by_label("t").is_some());
1068    }
1069
1070    #[test]
1071    fn critical_uses_escape_only_close_behavior() {
1072        use crate::button::Button as Btn;
1073        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1074        let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(
1075            MessageBox::critical(lit!("Fatal"))
1076                .text(lit!("Boom"))
1077                .buttons(MessageBoxButtons::Ok),
1078        )));
1079        let mb_for_closure = mb_cell.clone();
1080        let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
1081            if let Some(mb) = mb_for_closure.borrow_mut().take() {
1082                mb.present(ctx);
1083            }
1084        }));
1085        tree.layout(SizeProposal::exact(800.0, 600.0));
1086        tree.dispatch_event(WidgetEvent::AccessAction {
1087            action: teksilo_core::accesskit::Action::Click,
1088            target: Some(trigger),
1089            target_node: teksilo_core::accessibility::root_node_id(),
1090            data: None,
1091        });
1092        let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1093        assert_eq!(request.close_behavior, ModalCloseBehavior::EscapeKey);
1094    }
1095
1096    #[test]
1097    fn alert_dialog_role_exposed() {
1098        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1099        let mb = MessageBox::warning(lit!("Title"))
1100            .text(lit!("Body"))
1101            .buttons(MessageBoxButtons::Ok);
1102        let content = present_and_lay_out(&mut tree, mb);
1103        // `content` is the `ModalContainer`; its child is now the
1104        // `DialogStyle` panel frame, and the `MessageBox` sits one
1105        // level below that.
1106        let panel = tree.children(content).first().copied().unwrap();
1107        let mb_id = tree.children(panel).first().copied().unwrap();
1108        let info = tree.accessibility_node(mb_id);
1109        assert_eq!(info.role(), teksilo_core::accesskit::Role::AlertDialog);
1110        assert_eq!(info.name(), Some("Title"));
1111    }
1112
1113    #[test]
1114    fn ok_button_fires_result_with_correct_kind() {
1115        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1116        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1117        let captured_for_handler = captured.clone();
1118        let mb = MessageBox::information(lit!("t"))
1119            .text(lit!("x"))
1120            .buttons(MessageBoxButtons::Ok)
1121            .on_result(move |r, _ctx| {
1122                *captured_for_handler.borrow_mut() = Some(r);
1123            });
1124        let _content = present_and_lay_out(&mut tree, mb);
1125        let ok_id = tree
1126            .find_by_label(&StandardButton::Ok.default_label().resolve_now())
1127            .unwrap();
1128        tree.dispatch_event(WidgetEvent::AccessAction {
1129            action: teksilo_core::accesskit::Action::Click,
1130            target: Some(ok_id),
1131            target_node: teksilo_core::accessibility::root_node_id(),
1132            data: None,
1133        });
1134        let result = captured.borrow().expect("result must be captured");
1135        assert_eq!(result.button, StandardButton::Ok);
1136        assert!(!result.checkbox_checked);
1137        assert!(!result.dismissed_by_escape);
1138    }
1139
1140    #[test]
1141    fn default_button_is_focused_on_open() {
1142        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1143        let mb = MessageBox::question(lit!("t"))
1144            .text(lit!("x"))
1145            .buttons(MessageBoxButtons::YesNoCancel)
1146            .default_button(StandardButton::No);
1147        let _content = present_and_lay_out(&mut tree, mb);
1148        let no_id = tree
1149            .find_by_label(&StandardButton::No.default_label().resolve_now())
1150            .unwrap();
1151        assert_eq!(tree.focused(), Some(no_id));
1152    }
1153
1154    /// The reason this preset default exists. A Yes/No box is a confirmation,
1155    /// a confirmation usually precedes something destructive, and Enter is what
1156    /// a keyboard user presses on a dialog they have not finished reading.
1157    ///
1158    /// Focus placement is the whole contract: `Node::keyboard_shortcut` appears
1159    /// in none of the three AccessKit adapters, so no platform announces which
1160    /// button is the default. A screen-reader user finds out by triggering it.
1161    #[test]
1162    fn a_yes_no_box_defaults_to_no_so_enter_cannot_destroy() {
1163        for buttons in [MessageBoxButtons::YesNo, MessageBoxButtons::YesNoCancel] {
1164            let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1165            let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1166            let captured_for_handler = captured.clone();
1167            let mb = MessageBox::question(lit!("t"))
1168                .text(lit!("x"))
1169                .buttons(buttons.clone())
1170                .on_result(move |r, _ctx| {
1171                    *captured_for_handler.borrow_mut() = Some(r);
1172                });
1173            let _content = present_and_lay_out(&mut tree, mb);
1174
1175            let no_id = tree
1176                .find_by_label(&StandardButton::No.default_label().resolve_now())
1177                .unwrap();
1178            assert_eq!(
1179                tree.focused(),
1180                Some(no_id),
1181                "{buttons:?} must open with No focused"
1182            );
1183
1184            tree.press_key(Key::Enter, Modifiers::NONE);
1185            let result = captured.borrow().expect("result must be captured");
1186            assert_eq!(
1187                result.button,
1188                StandardButton::No,
1189                "{buttons:?}: Enter on an unread confirmation must not answer Yes"
1190            );
1191        }
1192    }
1193
1194    /// Escape and Enter are different buttons for `YesNoCancel`, and both
1195    /// matter: Escape means "I did not mean to be in this dialog", which is
1196    /// Cancel, while Enter answers the question that was asked.
1197    #[test]
1198    fn yes_no_cancel_keeps_cancel_as_the_escape_button() {
1199        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1200        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1201        let captured_for_handler = captured.clone();
1202        let mb = MessageBox::question(lit!("t"))
1203            .text(lit!("x"))
1204            .buttons(MessageBoxButtons::YesNoCancel)
1205            .on_result(move |r, _ctx| {
1206                *captured_for_handler.borrow_mut() = Some(r);
1207            });
1208        let _content = present_and_lay_out(&mut tree, mb);
1209        tree.press_key(Key::Escape, Modifiers::NONE);
1210        let result = captured.borrow().expect("result must be captured");
1211        assert_eq!(result.button, StandardButton::Cancel);
1212        assert!(result.dismissed_by_escape);
1213    }
1214
1215    /// The override still works, and is how a box that asks something safe
1216    /// gets Yes back.
1217    #[test]
1218    fn default_button_overrides_the_preset() {
1219        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1220        let mb = MessageBox::question(lit!("t"))
1221            .text(lit!("x"))
1222            .buttons(MessageBoxButtons::YesNo)
1223            .default_button(StandardButton::Yes);
1224        let _content = present_and_lay_out(&mut tree, mb);
1225        let yes_id = tree
1226            .find_by_label(&StandardButton::Yes.default_label().resolve_now())
1227            .unwrap();
1228        assert_eq!(tree.focused(), Some(yes_id));
1229    }
1230
1231    /// Enter answers for the button the user is standing on.
1232    ///
1233    /// This asserted the opposite until 0.9.3, and the opposite is what every
1234    /// caller of a `YesNo` box was living with: the shortcut that carries
1235    /// Enter is widget-scoped, so it is matched before the focused widget is
1236    /// offered the key, and the default answered for a button the user had
1237    /// deliberately tabbed to. In a confirmation whose safe answer is the
1238    /// default, that turns a deliberate Yes into a silent No.
1239    #[test]
1240    fn enter_fires_the_focused_button_rather_than_the_default() {
1241        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1242        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1243        let captured_for_handler = captured.clone();
1244        let mb = MessageBox::question(lit!("t"))
1245            .text(lit!("x"))
1246            .buttons(MessageBoxButtons::OkCancel)
1247            .on_result(move |r, _ctx| {
1248                *captured_for_handler.borrow_mut() = Some(r);
1249            });
1250        let _content = present_and_lay_out(&mut tree, mb);
1251        let cancel_id = tree
1252            .find_by_label(&StandardButton::Cancel.default_label().resolve_now())
1253            .unwrap();
1254        tree.focus(cancel_id);
1255        tree.press_key(Key::Enter, Modifiers::NONE);
1256        let result = captured.borrow().expect("result must be captured");
1257        assert_eq!(
1258            result.button,
1259            StandardButton::Cancel,
1260            "Enter must answer for the focused button, not for the default"
1261        );
1262        assert!(!result.dismissed_by_escape);
1263    }
1264
1265    /// And the default still answers when the focus is not on a button.
1266    ///
1267    /// The "show again" checkbox is the case that exists in a real box: it
1268    /// takes focus, and it handles Space rather than Enter, so Enter reaches
1269    /// the shortcut with the focus somewhere that is not one of the answers.
1270    #[test]
1271    fn enter_still_fires_the_default_when_the_focus_is_not_a_button() {
1272        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1273        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1274        let captured_for_handler = captured.clone();
1275        let mb = MessageBox::question(lit!("t"))
1276            .text(lit!("x"))
1277            .buttons(MessageBoxButtons::OkCancel)
1278            .show_again_checkbox(lit!("Ne plus demander"))
1279            .on_result(move |r, _ctx| {
1280                *captured_for_handler.borrow_mut() = Some(r);
1281            });
1282        let _content = present_and_lay_out(&mut tree, mb);
1283        let checkbox_id = tree.find_by_label("Ne plus demander").unwrap();
1284        tree.focus(checkbox_id);
1285        tree.press_key(Key::Enter, Modifiers::NONE);
1286        let result = captured.borrow().expect("result must be captured");
1287        assert_eq!(result.button, StandardButton::Ok);
1288        assert!(!result.dismissed_by_escape);
1289    }
1290
1291    #[test]
1292    fn escape_fires_escape_button_and_marks_dismissed() {
1293        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1294        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1295        let captured_for_handler = captured.clone();
1296        let mb = MessageBox::question(lit!("t"))
1297            .text(lit!("x"))
1298            .buttons(MessageBoxButtons::YesNoCancel)
1299            .on_result(move |r, _ctx| {
1300                *captured_for_handler.borrow_mut() = Some(r);
1301            });
1302        let _content = present_and_lay_out(&mut tree, mb);
1303        tree.press_key(Key::Escape, Modifiers::NONE);
1304        let result = captured.borrow().expect("result must be captured");
1305        assert_eq!(result.button, StandardButton::Cancel);
1306        assert!(result.dismissed_by_escape);
1307    }
1308
1309    #[test]
1310    fn checkbox_state_reported_in_result() {
1311        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1312        let shared_state = Signal::new(false);
1313        let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1314        let captured_for_handler = captured.clone();
1315        let mb = MessageBox::information(lit!("t"))
1316            .text(lit!("x"))
1317            .buttons(MessageBoxButtons::Ok)
1318            .show_again_checkbox_state(shared_state.clone())
1319            .show_again_checkbox(lit!("Don't show again"))
1320            .on_result(move |r, _ctx| {
1321                *captured_for_handler.borrow_mut() = Some(r);
1322            });
1323        let _content = present_and_lay_out(&mut tree, mb);
1324        shared_state.set(true);
1325        let ok_id = tree
1326            .find_by_label(&StandardButton::Ok.default_label().resolve_now())
1327            .unwrap();
1328        tree.dispatch_event(WidgetEvent::AccessAction {
1329            action: teksilo_core::accesskit::Action::Click,
1330            target: Some(ok_id),
1331            target_node: teksilo_core::accessibility::root_node_id(),
1332            data: None,
1333        });
1334        assert!(captured.borrow().unwrap().checkbox_checked);
1335    }
1336
1337    #[test]
1338    fn accessible_title_hint_propagates_to_container() {
1339        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1340        let mb = MessageBox::information(lit!("Title propagation test"))
1341            .text(lit!("Body"))
1342            .buttons(MessageBoxButtons::Ok);
1343        let content = present_and_lay_out(&mut tree, mb);
1344        let info = tree.accessibility_node(content);
1345        assert_eq!(info.role(), teksilo_core::accesskit::Role::Dialog);
1346        assert_eq!(info.name(), Some("Title propagation test"));
1347    }
1348
1349    #[test]
1350    fn standard_button_roles_classify_correctly() {
1351        assert_eq!(StandardButton::Ok.role(), ButtonRole::Accept);
1352        assert_eq!(StandardButton::Yes.role(), ButtonRole::Accept);
1353        assert_eq!(StandardButton::Save.role(), ButtonRole::Accept);
1354        assert_eq!(StandardButton::Cancel.role(), ButtonRole::Reject);
1355        assert_eq!(StandardButton::No.role(), ButtonRole::Reject);
1356        assert_eq!(StandardButton::Abort.role(), ButtonRole::Reject);
1357        assert_eq!(StandardButton::Discard.role(), ButtonRole::Destructive);
1358        assert_eq!(StandardButton::Help.role(), ButtonRole::Action);
1359        assert_eq!(StandardButton::Ignore.role(), ButtonRole::Action);
1360    }
1361
1362    /// A long `detailed_text` expands, stays reachable, and lays out without blowing up.
1363    ///
1364    /// The pane is where callers put the things with no length bound — a stack trace, an OS
1365    /// error dump, a list of every affected row — and it used to be a bare `TextWidget` in
1366    /// the accordion, so expanding it grew the dialog until the button row went off the
1367    /// bottom of the screen. It is capped and scrollable now (`DETAILS_MAX_HEIGHT`).
1368    ///
1369    /// This pins the reachable half: a hundred lines of detail still build, expand and
1370    /// re-lay out with the buttons intact. The *height* cap itself is not asserted here —
1371    /// `WidgetTree` exposes no laid-out geometry to widget tests, so there is nothing to
1372    /// measure against; that half is the `ScrollArea::preferred_height` contract, which
1373    /// `scroll_area` owns and tests.
1374    #[test]
1375    fn a_long_details_pane_expands_and_keeps_the_dialog_intact() {
1376        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1377        let long: String = (1..=100)
1378            .map(|i| format!("line {i} of a very long detail dump\n"))
1379            .collect();
1380        let mb = MessageBox::critical(lit!("Could not open file"))
1381            .text(lit!("It went wrong."))
1382            .detailed_text(lit!(long))
1383            .buttons(MessageBoxButtons::Ok);
1384        let _content = present_and_lay_out(&mut tree, mb);
1385
1386        let toggle = tree
1387            .find_by_label(&teksilo_i18n::tr_widget!(messagebox_show_details()).resolve_now())
1388            .expect("the Show details toggle");
1389        tree.dispatch_event(WidgetEvent::AccessAction {
1390            action: teksilo_core::accesskit::Action::Click,
1391            target: Some(toggle),
1392            target_node: teksilo_core::accessibility::root_node_id(),
1393            data: None,
1394        });
1395        tree.layout(SizeProposal::exact(800.0, 600.0));
1396
1397        // The dialog is still whole: its title and its button both survived the expansion.
1398        assert!(tree.find_by_label("Could not open file").is_some());
1399        assert!(
1400            tree.find_by_label(&StandardButton::Ok.default_label().resolve_now())
1401                .is_some(),
1402            "the button row must survive an expanded details pane"
1403        );
1404    }
1405
1406    #[test]
1407    fn escape_resolution_prefers_explicit_escape_button() {
1408        let state = State::new(Signal::new(false));
1409        *state.buttons.borrow_mut() = vec![StandardButton::Save, StandardButton::Discard];
1410        state.escape_button.set(Some(StandardButton::Discard));
1411        assert_eq!(state.resolve_escape_button(), Some(StandardButton::Discard));
1412    }
1413
1414    #[test]
1415    fn escape_resolution_falls_back_to_first_reject() {
1416        let state = State::new(Signal::new(false));
1417        *state.buttons.borrow_mut() = vec![
1418            StandardButton::Retry,
1419            StandardButton::Ignore,
1420            StandardButton::Abort,
1421        ];
1422        state.escape_button.set(None);
1423        assert_eq!(state.resolve_escape_button(), Some(StandardButton::Abort));
1424    }
1425
1426    #[test]
1427    fn escape_resolution_falls_back_to_last_when_no_reject() {
1428        let state = State::new(Signal::new(false));
1429        *state.buttons.borrow_mut() = vec![StandardButton::Ok, StandardButton::Help];
1430        state.escape_button.set(None);
1431        assert_eq!(state.resolve_escape_button(), Some(StandardButton::Help));
1432    }
1433}