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 fired: Cell<bool>,
473}
474
475impl State {
476 fn new(checkbox: Signal<bool>) -> Rc<Self> {
477 Rc::new(Self {
478 on_result: RefCell::new(None),
479 checkbox,
480 escape_button: Cell::new(None),
481 default_button: Cell::new(None),
482 buttons: RefCell::new(Vec::new()),
483 fired: Cell::new(false),
484 })
485 }
486
487 fn fire(&self, button: StandardButton, by_escape: bool, ctx: &mut EventContext) {
488 if self.fired.replace(true) {
489 return;
490 }
491 let result = MessageBoxResult {
492 button,
493 checkbox_checked: self.checkbox.get(),
494 dismissed_by_escape: by_escape,
495 };
496 if let Some(handler) = self.on_result.borrow().as_ref() {
497 handler(result, ctx);
498 }
499 ctx.dismiss_modal();
500 }
501
502 fn resolve_escape_button(&self) -> Option<StandardButton> {
503 if let Some(btn) = self.escape_button.get() {
504 return Some(btn);
505 }
506 let buttons = self.buttons.borrow();
507 if let Some(btn) = buttons.iter().find(|b| b.role() == ButtonRole::Reject) {
508 return Some(*btn);
509 }
510 if buttons.contains(&StandardButton::Cancel) {
511 return Some(StandardButton::Cancel);
512 }
513 buttons.last().copied()
514 }
515}
516
517pub struct MessageBox {
527 severity: MessageBoxSeverity,
528 title: LocalizedString,
529 text: Option<LocalizedString>,
530 informative_text: Option<LocalizedString>,
531 detailed_text: Option<LocalizedString>,
532 buttons_config: Option<MessageBoxButtons>,
533 extra_buttons: Vec<MessageBoxButton>,
534 default_button: Option<StandardButton>,
535 escape_button: Option<StandardButton>,
536 show_again_label: Option<LocalizedString>,
537 show_again_state: Option<Signal<bool>>,
538 on_result: Option<Box<dyn Fn(MessageBoxResult, &mut EventContext)>>,
539 default_button_id: Cell<Option<WidgetId>>,
540 root_child_id: Option<WidgetId>,
541 state: Option<Rc<State>>,
542}
543
544impl std::fmt::Debug for MessageBox {
545 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
546 f.debug_struct("MessageBox")
547 .field("severity", &self.severity)
548 .field("title", &self.title)
549 .field("text", &self.text)
550 .field("informative_text", &self.informative_text)
551 .field("detailed_text", &self.detailed_text)
552 .field("default_button", &self.default_button)
553 .field("escape_button", &self.escape_button)
554 .finish()
555 }
556}
557
558impl MessageBox {
559 fn new_with_severity(severity: MessageBoxSeverity, title: impl Into<LocalizedString>) -> Self {
560 let title = title.into();
561 Self {
562 severity,
563 title,
564 text: None,
565 informative_text: None,
566 detailed_text: None,
567 buttons_config: None,
568 extra_buttons: Vec::new(),
569 default_button: None,
570 escape_button: None,
571 show_again_label: None,
572 show_again_state: None,
573 on_result: None,
574 default_button_id: Cell::new(None),
575 root_child_id: None,
576 state: None,
577 }
578 }
579
580 pub fn information(title: impl Into<LocalizedString>) -> Self {
582 Self::new_with_severity(MessageBoxSeverity::Information, title)
583 }
584
585 pub fn warning(title: impl Into<LocalizedString>) -> Self {
587 Self::new_with_severity(MessageBoxSeverity::Warning, title)
588 }
589
590 pub fn critical(title: impl Into<LocalizedString>) -> Self {
594 Self::new_with_severity(MessageBoxSeverity::Critical, title)
595 }
596
597 pub fn question(title: impl Into<LocalizedString>) -> Self {
600 Self::new_with_severity(MessageBoxSeverity::Question, title)
601 }
602
603 pub fn plain(title: impl Into<LocalizedString>) -> Self {
605 Self::new_with_severity(MessageBoxSeverity::None, title)
606 }
607
608 pub fn text(mut self, text: impl Into<LocalizedString>) -> Self {
612 self.text = Some(text.into());
613 self
614 }
615
616 pub fn informative_text(mut self, text: impl Into<LocalizedString>) -> Self {
620 self.informative_text = Some(text.into());
621 self
622 }
623
624 pub fn detailed_text(mut self, text: impl Into<LocalizedString>) -> Self {
628 self.detailed_text = Some(text.into());
629 self
630 }
631
632 pub fn buttons(mut self, preset: MessageBoxButtons) -> Self {
637 if self.default_button.is_none() {
638 self.default_button = preset.preset_default();
639 }
640 if self.escape_button.is_none() {
641 self.escape_button = preset.preset_escape();
642 }
643 self.buttons_config = Some(preset);
644 self
645 }
646
647 pub fn add_button(mut self, button: impl Into<MessageBoxButton>) -> Self {
651 self.extra_buttons.push(button.into());
652 self
653 }
654
655 pub fn default_button(mut self, which: StandardButton) -> Self {
659 self.default_button = Some(which);
660 self
661 }
662
663 pub fn escape_button(mut self, which: StandardButton) -> Self {
666 self.escape_button = Some(which);
667 self
668 }
669
670 pub fn show_again_checkbox(mut self, label: impl Into<LocalizedString>) -> Self {
676 self.show_again_label = Some(label.into());
677 self
678 }
679
680 pub fn show_again_checkbox_state(mut self, signal: Signal<bool>) -> Self {
684 self.show_again_state = Some(signal);
685 self
686 }
687
688 pub fn on_result(mut self, f: impl Fn(MessageBoxResult, &mut EventContext) + 'static) -> Self {
691 self.on_result = Some(Box::new(f));
692 self
693 }
694
695 pub fn present(self, ctx: &mut EventContext) {
699 let title = self.title.clone();
700 let close_behavior = if self.severity == MessageBoxSeverity::Critical {
701 ModalCloseBehavior::EscapeKey
702 } else {
703 ModalCloseBehavior::EscapeOrClickOutside
704 };
705
706 let dialog_title = self.title.clone();
707 let mut inner = Some(self);
708 ctx.present_modal(
709 ModalRequest::deferred(move |tree| {
710 let mb = inner
711 .take()
712 .expect("MessageBox present closure called twice");
713 tree.add(ModalContainer::new(mb).title(dialog_title.clone()))
714 })
715 .presentation(ModalPresentation::Auto)
716 .close_behavior(close_behavior)
717 .title(title)
718 .size(460, 140),
719 );
720 }
721
722 fn resolve_buttons(&mut self) -> Vec<MessageBoxButton> {
723 let mut resolved = self
724 .buttons_config
725 .clone()
726 .map(|b| b.into_buttons())
727 .unwrap_or_default();
728 resolved.extend(self.extra_buttons.iter().cloned());
729 if resolved.is_empty() {
730 resolved.push(StandardButton::Ok.into());
731 if self.default_button.is_none() {
732 self.default_button = Some(StandardButton::Ok);
733 }
734 if self.escape_button.is_none() {
735 self.escape_button = Some(StandardButton::Ok);
736 }
737 }
738 resolved
739 }
740}
741
742impl Widget for MessageBox {
743 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
744 let theme = ctx.theme().clone();
745
746 let checkbox_signal = self
747 .show_again_state
748 .clone()
749 .unwrap_or_else(|| ctx.signal(false));
750 let state = State::new(checkbox_signal.clone());
751 *state.on_result.borrow_mut() = self.on_result.take();
752
753 let buttons = self.resolve_buttons();
754 *state.buttons.borrow_mut() = buttons.iter().map(|b| b.kind).collect();
755 state.default_button.set(self.default_button);
756 state.escape_button.set(self.escape_button);
757
758 let mut header_text_stack = VStack::new().spacing(6.0);
759 header_text_stack = header_text_stack.child(
760 TextWidget::new(self.title.clone())
761 .style(theme.typography.body_bold.clone())
762 .color(theme.colors.text_primary),
763 );
764 if let Some(text) = self.text.clone() {
765 header_text_stack = header_text_stack.child(
766 TextWidget::new(text)
767 .style(theme.typography.body.clone())
768 .color(theme.colors.text_primary),
769 );
770 }
771 if let Some(info) = self.informative_text.clone() {
772 header_text_stack = header_text_stack.child(
773 TextWidget::new(info)
774 .style(theme.typography.body.clone())
775 .color(theme.colors.text_secondary),
776 );
777 }
778
779 let header: Box<dyn Widget> = if let Some(kind) = severity_icon_kind(self.severity) {
780 Box::new(
786 HStack::new()
787 .spacing(16.0)
788 .alignment(VAlignment::Top)
789 .child(SeverityBadge::new(kind, SEVERITY_ICON_SIZE))
790 .child(Expand::horizontal().child(header_text_stack)),
791 )
792 } else {
793 Box::new(header_text_stack)
794 };
795
796 let detailed_child: Option<Box<dyn Widget>> = self.detailed_text.clone().map(|text| {
797 let expanded = ctx.signal(false);
798 let label: LocalizedString = teksilo_i18n::tr_widget!(messagebox_show_details());
799 let body = TextWidget::new(text)
800 .style(theme.typography.small.clone())
801 .color(theme.colors.text_secondary);
802 let scroller = ScrollArea::new()
808 .child(body)
809 .preferred_height(DETAILS_MAX_HEIGHT);
810 let accordion: Box<dyn Widget> =
811 Box::new(Accordion::new(label, expanded).content(scroller));
812 accordion
813 });
814
815 let checkbox_child: Option<Box<dyn Widget>> = self.show_again_label.clone().map(|label| {
816 let cb: Box<dyn Widget> = Box::new(Checkbox::new(checkbox_signal.clone()).label(label));
817 cb
818 });
819
820 let mut footer = HStack::new().spacing(8.0).child(Spacer::new());
821 for button_cfg in &buttons {
822 let kind = button_cfg.kind;
823 let label = button_cfg.resolved_label();
824 let variant = if Some(kind) == self.default_button {
825 ButtonVariant::Filled
826 } else {
827 ButtonVariant::Plain
828 };
829 let state_for_btn = state.clone();
830 let btn_id = ctx.add(
831 Button::new(label)
832 .variant(variant)
833 .on_activate_fn(move |ctx| {
834 state_for_btn.fire(kind, false, ctx);
835 }),
836 );
837 if Some(kind) == self.default_button {
838 self.default_button_id.set(Some(btn_id));
839 }
840 footer = footer.add_child(btn_id);
841 }
842
843 let mut stack = VStack::new().spacing(16.0);
844 stack = stack.add_child(ctx.add_boxed(header));
845 if let Some(det) = detailed_child {
846 stack = stack.add_child(ctx.add_boxed(det));
847 }
848 if let Some(cb) = checkbox_child {
849 stack = stack.add_child(ctx.add_boxed(cb));
850 }
851 stack = stack.add_child(ctx.add(Spacer::new()));
854 let footer_id = ctx.add(footer);
855 stack = stack.add_child(footer_id);
856
857 let root = ctx.add(stack);
858 self.root_child_id = Some(root);
859
860 {
861 let state_enter = state.clone();
862 ctx.register_action(
863 Action::new(DEFAULT_INTENT_NAME).on_invoke(move |_intent, ctx| {
864 if let Some(kind) = state_enter.default_button.get() {
865 state_enter.fire(kind, false, ctx);
866 }
867 }),
868 );
869 ctx.register_shortcut(
870 Shortcut::new(DEFAULT_INTENT_NAME)
871 .primary(KeyStroke::new(Key::Enter, Modifiers::NONE))
872 .build(),
873 );
874 }
875 {
876 let state_escape = state.clone();
877 ctx.register_action(
878 Action::new(ESCAPE_INTENT_NAME).on_invoke(move |_intent, ctx| {
879 if let Some(kind) = state_escape.resolve_escape_button() {
880 state_escape.fire(kind, true, ctx);
881 } else {
882 ctx.dismiss_modal();
883 }
884 }),
885 );
886 ctx.register_shortcut(
887 Shortcut::new(ESCAPE_INTENT_NAME)
888 .primary(KeyStroke::new(Key::Escape, Modifiers::NONE))
889 .build(),
890 );
891 }
892
893 self.state = Some(state);
894 vec![root]
895 }
896
897 fn layout_response(
898 &self,
899 proposal: SizeProposal,
900 ctx: &LayoutContext,
901 ) -> teksilo_core::widget::LayoutResponse {
902 let child = self
910 .root_child_id
911 .and_then(|id| ctx.child_size(id, proposal))
912 .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
913 Size::new(child.width.max(460.0), child.height.max(140.0)).into()
914 }
915
916 fn place_children(
917 &self,
918 bounds: Rect,
919 _proposal: SizeProposal,
920 children: &mut [WidgetPlacement],
921 _ctx: &LayoutContext,
922 ) {
923 for child in children.iter_mut() {
924 child.origin = bounds.origin();
925 child.size = bounds.size();
926 }
927 }
928
929 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
930
931 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
932 builder.set_role(teksilo_core::accesskit::Role::AlertDialog);
933 builder.set_name(self.title.clone());
934 if let Some(description) = self.accessible_description() {
935 builder.set_description(description);
936 }
937 builder.set_modal();
938 builder.set_live(teksilo_core::accesskit::Live::Assertive);
939 builder.add_action(teksilo_core::accesskit::Action::Focus);
940 }
941
942 fn accessible_title_hint(&self) -> Option<String> {
943 Some(self.title.resolve_now())
944 }
945
946 fn initial_focus_hint(&self) -> Option<WidgetId> {
947 self.default_button_id.get()
948 }
949
950 fn children(&self) -> Vec<WidgetId> {
951 self.root_child_id.into_iter().collect()
952 }
953}
954
955impl MessageBox {
956 fn accessible_description(&self) -> Option<String> {
957 match (
958 self.text.as_ref().map(|t| t.resolve_now()),
959 self.informative_text.as_ref().map(|i| i.resolve_now()),
960 ) {
961 (None, None) => None,
962 (Some(t), None) => Some(t),
963 (None, Some(i)) => Some(i),
964 (Some(t), Some(i)) => Some(format!("{t}\n{i}")),
965 }
966 }
967}
968
969pub trait EventContextMessageBoxExt {
973 fn present_message_box(&mut self, mb: MessageBox);
975}
976
977impl EventContextMessageBoxExt for EventContext<'_> {
978 fn present_message_box(&mut self, mb: MessageBox) {
979 mb.present(self);
980 }
981}
982
983#[cfg(test)]
984mod tests {
985 use super::*;
986 use teksilo_core::ModalContent;
987 use teksilo_core::event::WidgetEvent;
988 use teksilo_core::widget_tree::WidgetTree;
989 use teksilo_i18n::lit;
990
991 fn present_and_lay_out(tree: &mut WidgetTree, mb: MessageBox) -> WidgetId {
995 use crate::button::Button as Btn;
996 let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(mb)));
997 let mb_for_closure = mb_cell.clone();
998 let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
999 if let Some(mb) = mb_for_closure.borrow_mut().take() {
1000 mb.present(ctx);
1001 }
1002 }));
1003 tree.layout(SizeProposal::exact(800.0, 600.0));
1004 tree.dispatch_event(WidgetEvent::AccessAction {
1005 action: teksilo_core::accesskit::Action::Click,
1006 target: Some(trigger),
1007 target_node: teksilo_core::accessibility::root_node_id(),
1008 data: None,
1009 });
1010 let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1011 let content_id = match request.content {
1012 ModalContent::Deferred(builder) => builder(tree),
1013 ModalContent::ExistingWidget(_) => panic!("MessageBox must use deferred content"),
1014 };
1015 tree.layout(SizeProposal::exact(800.0, 600.0));
1016 let focus_target = request
1017 .focus_target
1018 .filter(|id| tree.is_active(*id) && tree.is_descendant_of(*id, content_id))
1019 .or_else(|| tree.widget_initial_focus_hint(content_id))
1020 .or_else(|| tree.first_focusable_descendant(content_id));
1021 if let Some(id) = focus_target {
1022 tree.focus(id);
1023 }
1024 content_id
1025 }
1026
1027 #[test]
1028 fn present_queues_modal_request() {
1029 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1030 let mb = MessageBox::information(lit!("t"))
1031 .text(lit!("x"))
1032 .buttons(MessageBoxButtons::Ok);
1033 let _content = present_and_lay_out(&mut tree, mb);
1034 assert!(tree.find_by_label("t").is_some());
1035 }
1036
1037 #[test]
1038 fn critical_uses_escape_only_close_behavior() {
1039 use crate::button::Button as Btn;
1040 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1041 let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(
1042 MessageBox::critical(lit!("Fatal"))
1043 .text(lit!("Boom"))
1044 .buttons(MessageBoxButtons::Ok),
1045 )));
1046 let mb_for_closure = mb_cell.clone();
1047 let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
1048 if let Some(mb) = mb_for_closure.borrow_mut().take() {
1049 mb.present(ctx);
1050 }
1051 }));
1052 tree.layout(SizeProposal::exact(800.0, 600.0));
1053 tree.dispatch_event(WidgetEvent::AccessAction {
1054 action: teksilo_core::accesskit::Action::Click,
1055 target: Some(trigger),
1056 target_node: teksilo_core::accessibility::root_node_id(),
1057 data: None,
1058 });
1059 let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1060 assert_eq!(request.close_behavior, ModalCloseBehavior::EscapeKey);
1061 }
1062
1063 #[test]
1064 fn alert_dialog_role_exposed() {
1065 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1066 let mb = MessageBox::warning(lit!("Title"))
1067 .text(lit!("Body"))
1068 .buttons(MessageBoxButtons::Ok);
1069 let content = present_and_lay_out(&mut tree, mb);
1070 let panel = tree.children(content).first().copied().unwrap();
1074 let mb_id = tree.children(panel).first().copied().unwrap();
1075 let info = tree.accessibility_node(mb_id);
1076 assert_eq!(info.role(), teksilo_core::accesskit::Role::AlertDialog);
1077 assert_eq!(info.name(), Some("Title"));
1078 }
1079
1080 #[test]
1081 fn ok_button_fires_result_with_correct_kind() {
1082 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1083 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1084 let captured_for_handler = captured.clone();
1085 let mb = MessageBox::information(lit!("t"))
1086 .text(lit!("x"))
1087 .buttons(MessageBoxButtons::Ok)
1088 .on_result(move |r, _ctx| {
1089 *captured_for_handler.borrow_mut() = Some(r);
1090 });
1091 let _content = present_and_lay_out(&mut tree, mb);
1092 let ok_id = tree
1093 .find_by_label(&StandardButton::Ok.default_label().resolve_now())
1094 .unwrap();
1095 tree.dispatch_event(WidgetEvent::AccessAction {
1096 action: teksilo_core::accesskit::Action::Click,
1097 target: Some(ok_id),
1098 target_node: teksilo_core::accessibility::root_node_id(),
1099 data: None,
1100 });
1101 let result = captured.borrow().expect("result must be captured");
1102 assert_eq!(result.button, StandardButton::Ok);
1103 assert!(!result.checkbox_checked);
1104 assert!(!result.dismissed_by_escape);
1105 }
1106
1107 #[test]
1108 fn default_button_is_focused_on_open() {
1109 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1110 let mb = MessageBox::question(lit!("t"))
1111 .text(lit!("x"))
1112 .buttons(MessageBoxButtons::YesNoCancel)
1113 .default_button(StandardButton::No);
1114 let _content = present_and_lay_out(&mut tree, mb);
1115 let no_id = tree
1116 .find_by_label(&StandardButton::No.default_label().resolve_now())
1117 .unwrap();
1118 assert_eq!(tree.focused(), Some(no_id));
1119 }
1120
1121 #[test]
1129 fn a_yes_no_box_defaults_to_no_so_enter_cannot_destroy() {
1130 for buttons in [MessageBoxButtons::YesNo, MessageBoxButtons::YesNoCancel] {
1131 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1132 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1133 let captured_for_handler = captured.clone();
1134 let mb = MessageBox::question(lit!("t"))
1135 .text(lit!("x"))
1136 .buttons(buttons.clone())
1137 .on_result(move |r, _ctx| {
1138 *captured_for_handler.borrow_mut() = Some(r);
1139 });
1140 let _content = present_and_lay_out(&mut tree, mb);
1141
1142 let no_id = tree
1143 .find_by_label(&StandardButton::No.default_label().resolve_now())
1144 .unwrap();
1145 assert_eq!(
1146 tree.focused(),
1147 Some(no_id),
1148 "{buttons:?} must open with No focused"
1149 );
1150
1151 tree.press_key(Key::Enter, Modifiers::NONE);
1152 let result = captured.borrow().expect("result must be captured");
1153 assert_eq!(
1154 result.button,
1155 StandardButton::No,
1156 "{buttons:?}: Enter on an unread confirmation must not answer Yes"
1157 );
1158 }
1159 }
1160
1161 #[test]
1165 fn yes_no_cancel_keeps_cancel_as_the_escape_button() {
1166 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1167 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1168 let captured_for_handler = captured.clone();
1169 let mb = MessageBox::question(lit!("t"))
1170 .text(lit!("x"))
1171 .buttons(MessageBoxButtons::YesNoCancel)
1172 .on_result(move |r, _ctx| {
1173 *captured_for_handler.borrow_mut() = Some(r);
1174 });
1175 let _content = present_and_lay_out(&mut tree, mb);
1176 tree.press_key(Key::Escape, Modifiers::NONE);
1177 let result = captured.borrow().expect("result must be captured");
1178 assert_eq!(result.button, StandardButton::Cancel);
1179 assert!(result.dismissed_by_escape);
1180 }
1181
1182 #[test]
1185 fn default_button_overrides_the_preset() {
1186 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1187 let mb = MessageBox::question(lit!("t"))
1188 .text(lit!("x"))
1189 .buttons(MessageBoxButtons::YesNo)
1190 .default_button(StandardButton::Yes);
1191 let _content = present_and_lay_out(&mut tree, mb);
1192 let yes_id = tree
1193 .find_by_label(&StandardButton::Yes.default_label().resolve_now())
1194 .unwrap();
1195 assert_eq!(tree.focused(), Some(yes_id));
1196 }
1197
1198 #[test]
1199 fn enter_fires_default_button_from_any_focus() {
1200 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1201 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1202 let captured_for_handler = captured.clone();
1203 let mb = MessageBox::question(lit!("t"))
1204 .text(lit!("x"))
1205 .buttons(MessageBoxButtons::OkCancel)
1206 .on_result(move |r, _ctx| {
1207 *captured_for_handler.borrow_mut() = Some(r);
1208 });
1209 let _content = present_and_lay_out(&mut tree, mb);
1210 let cancel_id = tree
1211 .find_by_label(&StandardButton::Cancel.default_label().resolve_now())
1212 .unwrap();
1213 tree.focus(cancel_id);
1214 tree.press_key(Key::Enter, Modifiers::NONE);
1215 let result = captured.borrow().expect("result must be captured");
1216 assert_eq!(result.button, StandardButton::Ok);
1217 assert!(!result.dismissed_by_escape);
1218 }
1219
1220 #[test]
1221 fn escape_fires_escape_button_and_marks_dismissed() {
1222 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1223 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1224 let captured_for_handler = captured.clone();
1225 let mb = MessageBox::question(lit!("t"))
1226 .text(lit!("x"))
1227 .buttons(MessageBoxButtons::YesNoCancel)
1228 .on_result(move |r, _ctx| {
1229 *captured_for_handler.borrow_mut() = Some(r);
1230 });
1231 let _content = present_and_lay_out(&mut tree, mb);
1232 tree.press_key(Key::Escape, Modifiers::NONE);
1233 let result = captured.borrow().expect("result must be captured");
1234 assert_eq!(result.button, StandardButton::Cancel);
1235 assert!(result.dismissed_by_escape);
1236 }
1237
1238 #[test]
1239 fn checkbox_state_reported_in_result() {
1240 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1241 let shared_state = Signal::new(false);
1242 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1243 let captured_for_handler = captured.clone();
1244 let mb = MessageBox::information(lit!("t"))
1245 .text(lit!("x"))
1246 .buttons(MessageBoxButtons::Ok)
1247 .show_again_checkbox_state(shared_state.clone())
1248 .show_again_checkbox(lit!("Don't show again"))
1249 .on_result(move |r, _ctx| {
1250 *captured_for_handler.borrow_mut() = Some(r);
1251 });
1252 let _content = present_and_lay_out(&mut tree, mb);
1253 shared_state.set(true);
1254 let ok_id = tree
1255 .find_by_label(&StandardButton::Ok.default_label().resolve_now())
1256 .unwrap();
1257 tree.dispatch_event(WidgetEvent::AccessAction {
1258 action: teksilo_core::accesskit::Action::Click,
1259 target: Some(ok_id),
1260 target_node: teksilo_core::accessibility::root_node_id(),
1261 data: None,
1262 });
1263 assert!(captured.borrow().unwrap().checkbox_checked);
1264 }
1265
1266 #[test]
1267 fn accessible_title_hint_propagates_to_container() {
1268 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1269 let mb = MessageBox::information(lit!("Title propagation test"))
1270 .text(lit!("Body"))
1271 .buttons(MessageBoxButtons::Ok);
1272 let content = present_and_lay_out(&mut tree, mb);
1273 let info = tree.accessibility_node(content);
1274 assert_eq!(info.role(), teksilo_core::accesskit::Role::Dialog);
1275 assert_eq!(info.name(), Some("Title propagation test"));
1276 }
1277
1278 #[test]
1279 fn standard_button_roles_classify_correctly() {
1280 assert_eq!(StandardButton::Ok.role(), ButtonRole::Accept);
1281 assert_eq!(StandardButton::Yes.role(), ButtonRole::Accept);
1282 assert_eq!(StandardButton::Save.role(), ButtonRole::Accept);
1283 assert_eq!(StandardButton::Cancel.role(), ButtonRole::Reject);
1284 assert_eq!(StandardButton::No.role(), ButtonRole::Reject);
1285 assert_eq!(StandardButton::Abort.role(), ButtonRole::Reject);
1286 assert_eq!(StandardButton::Discard.role(), ButtonRole::Destructive);
1287 assert_eq!(StandardButton::Help.role(), ButtonRole::Action);
1288 assert_eq!(StandardButton::Ignore.role(), ButtonRole::Action);
1289 }
1290
1291 #[test]
1304 fn a_long_details_pane_expands_and_keeps_the_dialog_intact() {
1305 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1306 let long: String = (1..=100)
1307 .map(|i| format!("line {i} of a very long detail dump\n"))
1308 .collect();
1309 let mb = MessageBox::critical(lit!("Could not open file"))
1310 .text(lit!("It went wrong."))
1311 .detailed_text(lit!(long))
1312 .buttons(MessageBoxButtons::Ok);
1313 let _content = present_and_lay_out(&mut tree, mb);
1314
1315 let toggle = tree
1316 .find_by_label(&teksilo_i18n::tr_widget!(messagebox_show_details()).resolve_now())
1317 .expect("the Show details toggle");
1318 tree.dispatch_event(WidgetEvent::AccessAction {
1319 action: teksilo_core::accesskit::Action::Click,
1320 target: Some(toggle),
1321 target_node: teksilo_core::accessibility::root_node_id(),
1322 data: None,
1323 });
1324 tree.layout(SizeProposal::exact(800.0, 600.0));
1325
1326 assert!(tree.find_by_label("Could not open file").is_some());
1328 assert!(
1329 tree.find_by_label(&StandardButton::Ok.default_label().resolve_now())
1330 .is_some(),
1331 "the button row must survive an expanded details pane"
1332 );
1333 }
1334
1335 #[test]
1336 fn escape_resolution_prefers_explicit_escape_button() {
1337 let state = State::new(Signal::new(false));
1338 *state.buttons.borrow_mut() = vec![StandardButton::Save, StandardButton::Discard];
1339 state.escape_button.set(Some(StandardButton::Discard));
1340 assert_eq!(state.resolve_escape_button(), Some(StandardButton::Discard));
1341 }
1342
1343 #[test]
1344 fn escape_resolution_falls_back_to_first_reject() {
1345 let state = State::new(Signal::new(false));
1346 *state.buttons.borrow_mut() = vec![
1347 StandardButton::Retry,
1348 StandardButton::Ignore,
1349 StandardButton::Abort,
1350 ];
1351 state.escape_button.set(None);
1352 assert_eq!(state.resolve_escape_button(), Some(StandardButton::Abort));
1353 }
1354
1355 #[test]
1356 fn escape_resolution_falls_back_to_last_when_no_reject() {
1357 let state = State::new(Signal::new(false));
1358 *state.buttons.borrow_mut() = vec![StandardButton::Ok, StandardButton::Help];
1359 state.escape_button.set(None);
1360 assert_eq!(state.resolve_escape_button(), Some(StandardButton::Help));
1361 }
1362}