1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130pub enum MessageBoxSeverity {
131 #[default]
133 None,
134 Information,
136 Question,
138 Warning,
140 Critical,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum ButtonRole {
154 Accept,
156 Reject,
158 Destructive,
161 Action,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
170pub enum StandardButton {
171 Ok,
173 Cancel,
175 Close,
177 Yes,
179 No,
181 YesToAll,
183 NoToAll,
185 Save,
187 SaveAll,
189 Discard,
191 Apply,
193 Reset,
195 RestoreDefaults,
197 Abort,
199 Retry,
201 Ignore,
203 Open,
205 Help,
207}
208
209impl StandardButton {
210 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 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 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#[derive(Debug, Clone)]
289pub struct MessageBoxButton {
290 pub kind: StandardButton,
293 pub label_override: Option<LocalizedString>,
297}
298
299impl MessageBoxButton {
300 pub fn standard(kind: StandardButton) -> Self {
302 Self {
303 kind,
304 label_override: None,
305 }
306 }
307
308 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#[derive(Debug, Clone)]
331pub enum MessageBoxButtons {
332 Ok,
334 OkCancel,
336 YesNo,
339 YesNoCancel,
342 SaveDiscardCancel,
344 RetryIgnoreAbort,
346 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 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 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#[derive(Debug, Clone, Copy)]
415pub struct MessageBoxResult {
416 pub button: StandardButton,
419 pub checkbox_checked: bool,
424 pub dismissed_by_escape: bool,
427}
428
429const SEVERITY_ICON_SIZE: f32 = 48.0;
430
431const DETAILS_MAX_HEIGHT: f32 = 220.0;
439
440const DEFAULT_INTENT_NAME: &str = "messagebox.accept_default";
441const ESCAPE_INTENT_NAME: &str = "messagebox.escape";
442
443fn 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
455struct 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 buttons: RefCell<Vec<StandardButton>>,
470 button_ids: RefCell<Vec<(WidgetId, StandardButton)>>,
474 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 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
531pub 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 pub fn information(title: impl Into<LocalizedString>) -> Self {
596 Self::new_with_severity(MessageBoxSeverity::Information, title)
597 }
598
599 pub fn warning(title: impl Into<LocalizedString>) -> Self {
601 Self::new_with_severity(MessageBoxSeverity::Warning, title)
602 }
603
604 pub fn critical(title: impl Into<LocalizedString>) -> Self {
608 Self::new_with_severity(MessageBoxSeverity::Critical, title)
609 }
610
611 pub fn question(title: impl Into<LocalizedString>) -> Self {
614 Self::new_with_severity(MessageBoxSeverity::Question, title)
615 }
616
617 pub fn plain(title: impl Into<LocalizedString>) -> Self {
619 Self::new_with_severity(MessageBoxSeverity::None, title)
620 }
621
622 pub fn text(mut self, text: impl Into<LocalizedString>) -> Self {
626 self.text = Some(text.into());
627 self
628 }
629
630 pub fn informative_text(mut self, text: impl Into<LocalizedString>) -> Self {
634 self.informative_text = Some(text.into());
635 self
636 }
637
638 pub fn detailed_text(mut self, text: impl Into<LocalizedString>) -> Self {
642 self.detailed_text = Some(text.into());
643 self
644 }
645
646 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 pub fn add_button(mut self, button: impl Into<MessageBoxButton>) -> Self {
665 self.extra_buttons.push(button.into());
666 self
667 }
668
669 pub fn default_button(mut self, which: StandardButton) -> Self {
673 self.default_button = Some(which);
674 self
675 }
676
677 pub fn escape_button(mut self, which: StandardButton) -> Self {
680 self.escape_button = Some(which);
681 self
682 }
683
684 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 pub fn show_again_checkbox_state(mut self, signal: Signal<bool>) -> Self {
698 self.show_again_state = Some(signal);
699 self
700 }
701
702 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 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 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 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 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 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 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
1002pub trait EventContextMessageBoxExt {
1006 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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}