1use std::cell::{Cell, RefCell};
83use std::rc::Rc;
84
85use teksilo_canvas::{Rect, Size, SizeProposal};
86use teksilo_core::accessibility::AccessNodeBuilder;
87use teksilo_core::action::Action;
88use teksilo_core::build_context::BuildContext;
89use teksilo_core::event::{Key, Modifiers};
90use teksilo_core::modal::{ModalCloseBehavior, ModalPresentation, ModalRequest};
91use teksilo_core::shortcut::{KeyStroke, Shortcut};
92use teksilo_core::signal::Signal;
93use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
94use teksilo_core::widget_id::WidgetId;
95use teksilo_i18n::LocalizedString;
96use teksilo_tokens::VAlignment;
97
98use crate::accordion::Accordion;
99use crate::button::{Button, ButtonVariant};
100use crate::checkbox::Checkbox;
101use crate::dialog::ModalContainer;
102use crate::primitives::{Expand, HStack, Spacer, TextWidget, VStack};
103use crate::scroll_area::ScrollArea;
104use crate::severity_badge::{SeverityBadge, SeverityIconKind};
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111pub enum MessageBoxSeverity {
112 #[default]
114 None,
115 Information,
117 Question,
119 Warning,
121 Critical,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum ButtonRole {
135 Accept,
137 Reject,
139 Destructive,
142 Action,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
151pub enum StandardButton {
152 Ok,
154 Cancel,
156 Close,
158 Yes,
160 No,
162 YesToAll,
164 NoToAll,
166 Save,
168 SaveAll,
170 Discard,
172 Apply,
174 Reset,
176 RestoreDefaults,
178 Abort,
180 Retry,
182 Ignore,
184 Open,
186 Help,
188}
189
190impl StandardButton {
191 pub fn role(self) -> ButtonRole {
195 match self {
196 Self::Ok
197 | Self::Yes
198 | Self::YesToAll
199 | Self::Save
200 | Self::SaveAll
201 | Self::Apply
202 | Self::Retry
203 | Self::Open => ButtonRole::Accept,
204 Self::Cancel | Self::Close | Self::No | Self::NoToAll | Self::Abort => {
205 ButtonRole::Reject
206 }
207 Self::Discard => ButtonRole::Destructive,
208 Self::Reset | Self::RestoreDefaults | Self::Ignore | Self::Help => ButtonRole::Action,
209 }
210 }
211
212 pub fn intent_name(self) -> &'static str {
217 match self {
218 Self::Ok => "messagebox.btn.ok",
219 Self::Cancel => "messagebox.btn.cancel",
220 Self::Close => "messagebox.btn.close",
221 Self::Yes => "messagebox.btn.yes",
222 Self::No => "messagebox.btn.no",
223 Self::YesToAll => "messagebox.btn.yes_to_all",
224 Self::NoToAll => "messagebox.btn.no_to_all",
225 Self::Save => "messagebox.btn.save",
226 Self::SaveAll => "messagebox.btn.save_all",
227 Self::Discard => "messagebox.btn.discard",
228 Self::Apply => "messagebox.btn.apply",
229 Self::Reset => "messagebox.btn.reset",
230 Self::RestoreDefaults => "messagebox.btn.restore_defaults",
231 Self::Abort => "messagebox.btn.abort",
232 Self::Retry => "messagebox.btn.retry",
233 Self::Ignore => "messagebox.btn.ignore",
234 Self::Open => "messagebox.btn.open",
235 Self::Help => "messagebox.btn.help",
236 }
237 }
238
239 pub fn default_label(self) -> LocalizedString {
242 match self {
243 Self::Ok => teksilo_i18n::tr_widget!(messagebox_btn_ok()),
244 Self::Cancel => teksilo_i18n::tr_widget!(messagebox_btn_cancel()),
245 Self::Close => teksilo_i18n::tr_widget!(messagebox_btn_close()),
246 Self::Yes => teksilo_i18n::tr_widget!(messagebox_btn_yes()),
247 Self::No => teksilo_i18n::tr_widget!(messagebox_btn_no()),
248 Self::YesToAll => teksilo_i18n::tr_widget!(messagebox_btn_yes_to_all()),
249 Self::NoToAll => teksilo_i18n::tr_widget!(messagebox_btn_no_to_all()),
250 Self::Save => teksilo_i18n::tr_widget!(messagebox_btn_save()),
251 Self::SaveAll => teksilo_i18n::tr_widget!(messagebox_btn_save_all()),
252 Self::Discard => teksilo_i18n::tr_widget!(messagebox_btn_discard()),
253 Self::Apply => teksilo_i18n::tr_widget!(messagebox_btn_apply()),
254 Self::Reset => teksilo_i18n::tr_widget!(messagebox_btn_reset()),
255 Self::RestoreDefaults => teksilo_i18n::tr_widget!(messagebox_btn_restore_defaults()),
256 Self::Abort => teksilo_i18n::tr_widget!(messagebox_btn_abort()),
257 Self::Retry => teksilo_i18n::tr_widget!(messagebox_btn_retry()),
258 Self::Ignore => teksilo_i18n::tr_widget!(messagebox_btn_ignore()),
259 Self::Open => teksilo_i18n::tr_widget!(messagebox_btn_open()),
260 Self::Help => teksilo_i18n::tr_widget!(messagebox_btn_help()),
261 }
262 }
263}
264
265#[derive(Debug, Clone)]
270pub struct MessageBoxButton {
271 pub kind: StandardButton,
274 pub label_override: Option<LocalizedString>,
278}
279
280impl MessageBoxButton {
281 pub fn standard(kind: StandardButton) -> Self {
283 Self {
284 kind,
285 label_override: None,
286 }
287 }
288
289 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
291 self.label_override = Some(label.into());
292 self
293 }
294
295 fn resolved_label(&self) -> LocalizedString {
296 self.label_override
297 .clone()
298 .unwrap_or_else(|| self.kind.default_label())
299 }
300}
301
302impl From<StandardButton> for MessageBoxButton {
303 fn from(kind: StandardButton) -> Self {
304 Self::standard(kind)
305 }
306}
307
308#[derive(Debug, Clone)]
312pub enum MessageBoxButtons {
313 Ok,
315 OkCancel,
317 YesNo,
319 YesNoCancel,
321 SaveDiscardCancel,
323 RetryIgnoreAbort,
325 Custom(Vec<MessageBoxButton>),
329}
330
331impl MessageBoxButtons {
332 fn into_buttons(self) -> Vec<MessageBoxButton> {
333 match self {
334 Self::Ok => vec![StandardButton::Ok.into()],
335 Self::OkCancel => vec![StandardButton::Cancel.into(), StandardButton::Ok.into()],
336 Self::YesNo => vec![StandardButton::No.into(), StandardButton::Yes.into()],
337 Self::YesNoCancel => vec![
338 StandardButton::Cancel.into(),
339 StandardButton::No.into(),
340 StandardButton::Yes.into(),
341 ],
342 Self::SaveDiscardCancel => vec![
343 StandardButton::Discard.into(),
344 StandardButton::Cancel.into(),
345 StandardButton::Save.into(),
346 ],
347 Self::RetryIgnoreAbort => vec![
348 StandardButton::Abort.into(),
349 StandardButton::Ignore.into(),
350 StandardButton::Retry.into(),
351 ],
352 Self::Custom(items) => items,
353 }
354 }
355
356 fn preset_default(&self) -> Option<StandardButton> {
359 match self {
360 Self::Ok => Some(StandardButton::Ok),
361 Self::OkCancel => Some(StandardButton::Ok),
362 Self::YesNo => Some(StandardButton::Yes),
363 Self::YesNoCancel => Some(StandardButton::Yes),
364 Self::SaveDiscardCancel => Some(StandardButton::Save),
365 Self::RetryIgnoreAbort => Some(StandardButton::Retry),
366 Self::Custom(_) => None,
367 }
368 }
369
370 fn preset_escape(&self) -> Option<StandardButton> {
372 match self {
373 Self::Ok => Some(StandardButton::Ok),
374 Self::OkCancel => Some(StandardButton::Cancel),
375 Self::YesNo => Some(StandardButton::No),
376 Self::YesNoCancel => Some(StandardButton::Cancel),
377 Self::SaveDiscardCancel => Some(StandardButton::Cancel),
378 Self::RetryIgnoreAbort => Some(StandardButton::Abort),
379 Self::Custom(_) => None,
380 }
381 }
382}
383
384#[derive(Debug, Clone, Copy)]
388pub struct MessageBoxResult {
389 pub button: StandardButton,
392 pub checkbox_checked: bool,
397 pub dismissed_by_escape: bool,
400}
401
402const SEVERITY_ICON_SIZE: f32 = 48.0;
403
404const DETAILS_MAX_HEIGHT: f32 = 220.0;
412
413const DEFAULT_INTENT_NAME: &str = "messagebox.accept_default";
414const ESCAPE_INTENT_NAME: &str = "messagebox.escape";
415
416fn severity_icon_kind(severity: MessageBoxSeverity) -> Option<SeverityIconKind> {
419 match severity {
420 MessageBoxSeverity::None => None,
421 MessageBoxSeverity::Information => Some(SeverityIconKind::Info),
422 MessageBoxSeverity::Question => Some(SeverityIconKind::Question),
423 MessageBoxSeverity::Warning => Some(SeverityIconKind::Warning),
424 MessageBoxSeverity::Critical => Some(SeverityIconKind::Error),
425 }
426}
427
428struct State {
435 on_result: RefCell<Option<Box<dyn Fn(MessageBoxResult, &mut EventContext)>>>,
436 checkbox: Signal<bool>,
437 escape_button: Cell<Option<StandardButton>>,
438 default_button: Cell<Option<StandardButton>>,
439 buttons: RefCell<Vec<StandardButton>>,
443 fired: Cell<bool>,
446}
447
448impl State {
449 fn new(checkbox: Signal<bool>) -> Rc<Self> {
450 Rc::new(Self {
451 on_result: RefCell::new(None),
452 checkbox,
453 escape_button: Cell::new(None),
454 default_button: Cell::new(None),
455 buttons: RefCell::new(Vec::new()),
456 fired: Cell::new(false),
457 })
458 }
459
460 fn fire(&self, button: StandardButton, by_escape: bool, ctx: &mut EventContext) {
461 if self.fired.replace(true) {
462 return;
463 }
464 let result = MessageBoxResult {
465 button,
466 checkbox_checked: self.checkbox.get(),
467 dismissed_by_escape: by_escape,
468 };
469 if let Some(handler) = self.on_result.borrow().as_ref() {
470 handler(result, ctx);
471 }
472 ctx.dismiss_modal();
473 }
474
475 fn resolve_escape_button(&self) -> Option<StandardButton> {
476 if let Some(btn) = self.escape_button.get() {
477 return Some(btn);
478 }
479 let buttons = self.buttons.borrow();
480 if let Some(btn) = buttons.iter().find(|b| b.role() == ButtonRole::Reject) {
481 return Some(*btn);
482 }
483 if buttons.contains(&StandardButton::Cancel) {
484 return Some(StandardButton::Cancel);
485 }
486 buttons.last().copied()
487 }
488}
489
490pub struct MessageBox {
500 severity: MessageBoxSeverity,
501 title: LocalizedString,
502 text: Option<LocalizedString>,
503 informative_text: Option<LocalizedString>,
504 detailed_text: Option<LocalizedString>,
505 buttons_config: Option<MessageBoxButtons>,
506 extra_buttons: Vec<MessageBoxButton>,
507 default_button: Option<StandardButton>,
508 escape_button: Option<StandardButton>,
509 show_again_label: Option<LocalizedString>,
510 show_again_state: Option<Signal<bool>>,
511 on_result: Option<Box<dyn Fn(MessageBoxResult, &mut EventContext)>>,
512 default_button_id: Cell<Option<WidgetId>>,
513 root_child_id: Option<WidgetId>,
514 state: Option<Rc<State>>,
515}
516
517impl std::fmt::Debug for MessageBox {
518 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519 f.debug_struct("MessageBox")
520 .field("severity", &self.severity)
521 .field("title", &self.title)
522 .field("text", &self.text)
523 .field("informative_text", &self.informative_text)
524 .field("detailed_text", &self.detailed_text)
525 .field("default_button", &self.default_button)
526 .field("escape_button", &self.escape_button)
527 .finish()
528 }
529}
530
531impl MessageBox {
532 fn new_with_severity(severity: MessageBoxSeverity, title: impl Into<LocalizedString>) -> Self {
533 let title = title.into();
534 Self {
535 severity,
536 title,
537 text: None,
538 informative_text: None,
539 detailed_text: None,
540 buttons_config: None,
541 extra_buttons: Vec::new(),
542 default_button: None,
543 escape_button: None,
544 show_again_label: None,
545 show_again_state: None,
546 on_result: None,
547 default_button_id: Cell::new(None),
548 root_child_id: None,
549 state: None,
550 }
551 }
552
553 pub fn information(title: impl Into<LocalizedString>) -> Self {
555 Self::new_with_severity(MessageBoxSeverity::Information, title)
556 }
557
558 pub fn warning(title: impl Into<LocalizedString>) -> Self {
560 Self::new_with_severity(MessageBoxSeverity::Warning, title)
561 }
562
563 pub fn critical(title: impl Into<LocalizedString>) -> Self {
567 Self::new_with_severity(MessageBoxSeverity::Critical, title)
568 }
569
570 pub fn question(title: impl Into<LocalizedString>) -> Self {
573 Self::new_with_severity(MessageBoxSeverity::Question, title)
574 }
575
576 pub fn plain(title: impl Into<LocalizedString>) -> Self {
578 Self::new_with_severity(MessageBoxSeverity::None, title)
579 }
580
581 pub fn text(mut self, text: impl Into<LocalizedString>) -> Self {
585 self.text = Some(text.into());
586 self
587 }
588
589 pub fn informative_text(mut self, text: impl Into<LocalizedString>) -> Self {
593 self.informative_text = Some(text.into());
594 self
595 }
596
597 pub fn detailed_text(mut self, text: impl Into<LocalizedString>) -> Self {
601 self.detailed_text = Some(text.into());
602 self
603 }
604
605 pub fn buttons(mut self, preset: MessageBoxButtons) -> Self {
610 if self.default_button.is_none() {
611 self.default_button = preset.preset_default();
612 }
613 if self.escape_button.is_none() {
614 self.escape_button = preset.preset_escape();
615 }
616 self.buttons_config = Some(preset);
617 self
618 }
619
620 pub fn add_button(mut self, button: impl Into<MessageBoxButton>) -> Self {
624 self.extra_buttons.push(button.into());
625 self
626 }
627
628 pub fn default_button(mut self, which: StandardButton) -> Self {
632 self.default_button = Some(which);
633 self
634 }
635
636 pub fn escape_button(mut self, which: StandardButton) -> Self {
639 self.escape_button = Some(which);
640 self
641 }
642
643 pub fn show_again_checkbox(mut self, label: impl Into<LocalizedString>) -> Self {
649 self.show_again_label = Some(label.into());
650 self
651 }
652
653 pub fn show_again_checkbox_state(mut self, signal: Signal<bool>) -> Self {
657 self.show_again_state = Some(signal);
658 self
659 }
660
661 pub fn on_result(mut self, f: impl Fn(MessageBoxResult, &mut EventContext) + 'static) -> Self {
664 self.on_result = Some(Box::new(f));
665 self
666 }
667
668 pub fn present(self, ctx: &mut EventContext) {
672 let title = self.title.clone();
673 let close_behavior = if self.severity == MessageBoxSeverity::Critical {
674 ModalCloseBehavior::EscapeKey
675 } else {
676 ModalCloseBehavior::EscapeOrClickOutside
677 };
678
679 let dialog_title = self.title.clone();
680 let mut inner = Some(self);
681 ctx.present_modal(
682 ModalRequest::deferred(move |tree| {
683 let mb = inner
684 .take()
685 .expect("MessageBox present closure called twice");
686 tree.add(ModalContainer::new(mb).title(dialog_title.clone()))
687 })
688 .presentation(ModalPresentation::Auto)
689 .close_behavior(close_behavior)
690 .title(title)
691 .size(460, 140),
692 );
693 }
694
695 fn resolve_buttons(&mut self) -> Vec<MessageBoxButton> {
696 let mut resolved = self
697 .buttons_config
698 .clone()
699 .map(|b| b.into_buttons())
700 .unwrap_or_default();
701 resolved.extend(self.extra_buttons.iter().cloned());
702 if resolved.is_empty() {
703 resolved.push(StandardButton::Ok.into());
704 if self.default_button.is_none() {
705 self.default_button = Some(StandardButton::Ok);
706 }
707 if self.escape_button.is_none() {
708 self.escape_button = Some(StandardButton::Ok);
709 }
710 }
711 resolved
712 }
713}
714
715impl Widget for MessageBox {
716 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
717 let theme = ctx.theme().clone();
718
719 let checkbox_signal = self
720 .show_again_state
721 .clone()
722 .unwrap_or_else(|| ctx.signal(false));
723 let state = State::new(checkbox_signal.clone());
724 *state.on_result.borrow_mut() = self.on_result.take();
725
726 let buttons = self.resolve_buttons();
727 *state.buttons.borrow_mut() = buttons.iter().map(|b| b.kind).collect();
728 state.default_button.set(self.default_button);
729 state.escape_button.set(self.escape_button);
730
731 let mut header_text_stack = VStack::new().spacing(6.0);
732 header_text_stack = header_text_stack.child(
733 TextWidget::new(self.title.clone())
734 .style(theme.typography.body_bold.clone())
735 .color(theme.colors.text_primary),
736 );
737 if let Some(text) = self.text.clone() {
738 header_text_stack = header_text_stack.child(
739 TextWidget::new(text)
740 .style(theme.typography.body.clone())
741 .color(theme.colors.text_primary),
742 );
743 }
744 if let Some(info) = self.informative_text.clone() {
745 header_text_stack = header_text_stack.child(
746 TextWidget::new(info)
747 .style(theme.typography.body.clone())
748 .color(theme.colors.text_secondary),
749 );
750 }
751
752 let header: Box<dyn Widget> = if let Some(kind) = severity_icon_kind(self.severity) {
753 Box::new(
759 HStack::new()
760 .spacing(16.0)
761 .alignment(VAlignment::Top)
762 .child(SeverityBadge::new(kind, SEVERITY_ICON_SIZE))
763 .child(Expand::horizontal().child(header_text_stack)),
764 )
765 } else {
766 Box::new(header_text_stack)
767 };
768
769 let detailed_child: Option<Box<dyn Widget>> = self.detailed_text.clone().map(|text| {
770 let expanded = ctx.signal(false);
771 let label: LocalizedString = teksilo_i18n::tr_widget!(messagebox_show_details());
772 let body = TextWidget::new(text)
773 .style(theme.typography.small.clone())
774 .color(theme.colors.text_secondary);
775 let scroller = ScrollArea::new()
781 .child(body)
782 .preferred_height(DETAILS_MAX_HEIGHT);
783 let accordion: Box<dyn Widget> =
784 Box::new(Accordion::new(label, expanded).content(scroller));
785 accordion
786 });
787
788 let checkbox_child: Option<Box<dyn Widget>> = self.show_again_label.clone().map(|label| {
789 let cb: Box<dyn Widget> = Box::new(Checkbox::new(checkbox_signal.clone()).label(label));
790 cb
791 });
792
793 let mut footer = HStack::new().spacing(8.0).child(Spacer::new());
794 for button_cfg in &buttons {
795 let kind = button_cfg.kind;
796 let label = button_cfg.resolved_label();
797 let variant = if Some(kind) == self.default_button {
798 ButtonVariant::Filled
799 } else {
800 ButtonVariant::Plain
801 };
802 let state_for_btn = state.clone();
803 let btn_id = ctx.add(
804 Button::new(label)
805 .variant(variant)
806 .on_activate_fn(move |ctx| {
807 state_for_btn.fire(kind, false, ctx);
808 }),
809 );
810 if Some(kind) == self.default_button {
811 self.default_button_id.set(Some(btn_id));
812 }
813 footer = footer.add_child(btn_id);
814 }
815
816 let mut stack = VStack::new().spacing(16.0);
817 stack = stack.add_child(ctx.add_boxed(header));
818 if let Some(det) = detailed_child {
819 stack = stack.add_child(ctx.add_boxed(det));
820 }
821 if let Some(cb) = checkbox_child {
822 stack = stack.add_child(ctx.add_boxed(cb));
823 }
824 stack = stack.add_child(ctx.add(Spacer::new()));
827 let footer_id = ctx.add(footer);
828 stack = stack.add_child(footer_id);
829
830 let root = ctx.add(stack);
831 self.root_child_id = Some(root);
832
833 {
834 let state_enter = state.clone();
835 ctx.register_action(
836 Action::new(DEFAULT_INTENT_NAME).on_invoke(move |_intent, ctx| {
837 if let Some(kind) = state_enter.default_button.get() {
838 state_enter.fire(kind, false, ctx);
839 }
840 }),
841 );
842 ctx.register_shortcut(
843 Shortcut::new(DEFAULT_INTENT_NAME)
844 .primary(KeyStroke::new(Key::Enter, Modifiers::NONE))
845 .build(),
846 );
847 }
848 {
849 let state_escape = state.clone();
850 ctx.register_action(
851 Action::new(ESCAPE_INTENT_NAME).on_invoke(move |_intent, ctx| {
852 if let Some(kind) = state_escape.resolve_escape_button() {
853 state_escape.fire(kind, true, ctx);
854 } else {
855 ctx.dismiss_modal();
856 }
857 }),
858 );
859 ctx.register_shortcut(
860 Shortcut::new(ESCAPE_INTENT_NAME)
861 .primary(KeyStroke::new(Key::Escape, Modifiers::NONE))
862 .build(),
863 );
864 }
865
866 self.state = Some(state);
867 vec![root]
868 }
869
870 fn layout_response(
871 &self,
872 proposal: SizeProposal,
873 ctx: &LayoutContext,
874 ) -> teksilo_core::widget::LayoutResponse {
875 let child = self
883 .root_child_id
884 .and_then(|id| ctx.child_size(id, proposal))
885 .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
886 Size::new(child.width.max(460.0), child.height.max(140.0)).into()
887 }
888
889 fn place_children(
890 &self,
891 bounds: Rect,
892 _proposal: SizeProposal,
893 children: &mut [WidgetPlacement],
894 _ctx: &LayoutContext,
895 ) {
896 for child in children.iter_mut() {
897 child.origin = bounds.origin();
898 child.size = bounds.size();
899 }
900 }
901
902 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
903
904 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
905 builder.set_role(teksilo_core::accesskit::Role::AlertDialog);
906 builder.set_name(self.title.clone());
907 if let Some(description) = self.accessible_description() {
908 builder.set_description(description);
909 }
910 builder.set_modal();
911 builder.set_live(teksilo_core::accesskit::Live::Assertive);
912 builder.add_action(teksilo_core::accesskit::Action::Focus);
913 }
914
915 fn accessible_title_hint(&self) -> Option<String> {
916 Some(self.title.resolve_now())
917 }
918
919 fn initial_focus_hint(&self) -> Option<WidgetId> {
920 self.default_button_id.get()
921 }
922
923 fn children(&self) -> Vec<WidgetId> {
924 self.root_child_id.into_iter().collect()
925 }
926}
927
928impl MessageBox {
929 fn accessible_description(&self) -> Option<String> {
930 match (
931 self.text.as_ref().map(|t| t.resolve_now()),
932 self.informative_text.as_ref().map(|i| i.resolve_now()),
933 ) {
934 (None, None) => None,
935 (Some(t), None) => Some(t),
936 (None, Some(i)) => Some(i),
937 (Some(t), Some(i)) => Some(format!("{t}\n{i}")),
938 }
939 }
940}
941
942pub trait EventContextMessageBoxExt {
946 fn present_message_box(&mut self, mb: MessageBox);
948}
949
950impl EventContextMessageBoxExt for EventContext<'_> {
951 fn present_message_box(&mut self, mb: MessageBox) {
952 mb.present(self);
953 }
954}
955
956#[cfg(test)]
957mod tests {
958 use super::*;
959 use teksilo_core::ModalContent;
960 use teksilo_core::event::WidgetEvent;
961 use teksilo_core::widget_tree::WidgetTree;
962 use teksilo_i18n::lit;
963
964 fn present_and_lay_out(tree: &mut WidgetTree, mb: MessageBox) -> WidgetId {
968 use crate::button::Button as Btn;
969 let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(mb)));
970 let mb_for_closure = mb_cell.clone();
971 let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
972 if let Some(mb) = mb_for_closure.borrow_mut().take() {
973 mb.present(ctx);
974 }
975 }));
976 tree.layout(SizeProposal::exact(800.0, 600.0));
977 tree.dispatch_event(WidgetEvent::AccessAction {
978 action: teksilo_core::accesskit::Action::Click,
979 target: Some(trigger),
980 target_node: teksilo_core::accessibility::root_node_id(),
981 data: None,
982 });
983 let request = tree.drain_pending_modal_requests().pop().unwrap().request;
984 let content_id = match request.content {
985 ModalContent::Deferred(builder) => builder(tree),
986 ModalContent::ExistingWidget(_) => panic!("MessageBox must use deferred content"),
987 };
988 tree.layout(SizeProposal::exact(800.0, 600.0));
989 let focus_target = request
990 .focus_target
991 .filter(|id| tree.is_active(*id) && tree.is_descendant_of(*id, content_id))
992 .or_else(|| tree.widget_initial_focus_hint(content_id))
993 .or_else(|| tree.first_focusable_descendant(content_id));
994 if let Some(id) = focus_target {
995 tree.focus(id);
996 }
997 content_id
998 }
999
1000 #[test]
1001 fn present_queues_modal_request() {
1002 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1003 let mb = MessageBox::information(lit!("t"))
1004 .text(lit!("x"))
1005 .buttons(MessageBoxButtons::Ok);
1006 let _content = present_and_lay_out(&mut tree, mb);
1007 assert!(tree.find_by_label("t").is_some());
1008 }
1009
1010 #[test]
1011 fn critical_uses_escape_only_close_behavior() {
1012 use crate::button::Button as Btn;
1013 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1014 let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(
1015 MessageBox::critical(lit!("Fatal"))
1016 .text(lit!("Boom"))
1017 .buttons(MessageBoxButtons::Ok),
1018 )));
1019 let mb_for_closure = mb_cell.clone();
1020 let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
1021 if let Some(mb) = mb_for_closure.borrow_mut().take() {
1022 mb.present(ctx);
1023 }
1024 }));
1025 tree.layout(SizeProposal::exact(800.0, 600.0));
1026 tree.dispatch_event(WidgetEvent::AccessAction {
1027 action: teksilo_core::accesskit::Action::Click,
1028 target: Some(trigger),
1029 target_node: teksilo_core::accessibility::root_node_id(),
1030 data: None,
1031 });
1032 let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1033 assert_eq!(request.close_behavior, ModalCloseBehavior::EscapeKey);
1034 }
1035
1036 #[test]
1037 fn alert_dialog_role_exposed() {
1038 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1039 let mb = MessageBox::warning(lit!("Title"))
1040 .text(lit!("Body"))
1041 .buttons(MessageBoxButtons::Ok);
1042 let content = present_and_lay_out(&mut tree, mb);
1043 let panel = tree.children(content).first().copied().unwrap();
1047 let mb_id = tree.children(panel).first().copied().unwrap();
1048 let info = tree.accessibility_node(mb_id);
1049 assert_eq!(info.role(), teksilo_core::accesskit::Role::AlertDialog);
1050 assert_eq!(info.name(), Some("Title"));
1051 }
1052
1053 #[test]
1054 fn ok_button_fires_result_with_correct_kind() {
1055 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1056 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1057 let captured_for_handler = captured.clone();
1058 let mb = MessageBox::information(lit!("t"))
1059 .text(lit!("x"))
1060 .buttons(MessageBoxButtons::Ok)
1061 .on_result(move |r, _ctx| {
1062 *captured_for_handler.borrow_mut() = Some(r);
1063 });
1064 let _content = present_and_lay_out(&mut tree, mb);
1065 let ok_id = tree
1066 .find_by_label(&StandardButton::Ok.default_label().resolve_now())
1067 .unwrap();
1068 tree.dispatch_event(WidgetEvent::AccessAction {
1069 action: teksilo_core::accesskit::Action::Click,
1070 target: Some(ok_id),
1071 target_node: teksilo_core::accessibility::root_node_id(),
1072 data: None,
1073 });
1074 let result = captured.borrow().expect("result must be captured");
1075 assert_eq!(result.button, StandardButton::Ok);
1076 assert!(!result.checkbox_checked);
1077 assert!(!result.dismissed_by_escape);
1078 }
1079
1080 #[test]
1081 fn default_button_is_focused_on_open() {
1082 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1083 let mb = MessageBox::question(lit!("t"))
1084 .text(lit!("x"))
1085 .buttons(MessageBoxButtons::YesNoCancel)
1086 .default_button(StandardButton::No);
1087 let _content = present_and_lay_out(&mut tree, mb);
1088 let no_id = tree
1089 .find_by_label(&StandardButton::No.default_label().resolve_now())
1090 .unwrap();
1091 assert_eq!(tree.focused(), Some(no_id));
1092 }
1093
1094 #[test]
1095 fn enter_fires_default_button_from_any_focus() {
1096 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1097 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1098 let captured_for_handler = captured.clone();
1099 let mb = MessageBox::question(lit!("t"))
1100 .text(lit!("x"))
1101 .buttons(MessageBoxButtons::OkCancel)
1102 .on_result(move |r, _ctx| {
1103 *captured_for_handler.borrow_mut() = Some(r);
1104 });
1105 let _content = present_and_lay_out(&mut tree, mb);
1106 let cancel_id = tree
1107 .find_by_label(&StandardButton::Cancel.default_label().resolve_now())
1108 .unwrap();
1109 tree.focus(cancel_id);
1110 tree.press_key(Key::Enter, Modifiers::NONE);
1111 let result = captured.borrow().expect("result must be captured");
1112 assert_eq!(result.button, StandardButton::Ok);
1113 assert!(!result.dismissed_by_escape);
1114 }
1115
1116 #[test]
1117 fn escape_fires_escape_button_and_marks_dismissed() {
1118 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1119 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1120 let captured_for_handler = captured.clone();
1121 let mb = MessageBox::question(lit!("t"))
1122 .text(lit!("x"))
1123 .buttons(MessageBoxButtons::YesNoCancel)
1124 .on_result(move |r, _ctx| {
1125 *captured_for_handler.borrow_mut() = Some(r);
1126 });
1127 let _content = present_and_lay_out(&mut tree, mb);
1128 tree.press_key(Key::Escape, Modifiers::NONE);
1129 let result = captured.borrow().expect("result must be captured");
1130 assert_eq!(result.button, StandardButton::Cancel);
1131 assert!(result.dismissed_by_escape);
1132 }
1133
1134 #[test]
1135 fn checkbox_state_reported_in_result() {
1136 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1137 let shared_state = Signal::new(false);
1138 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1139 let captured_for_handler = captured.clone();
1140 let mb = MessageBox::information(lit!("t"))
1141 .text(lit!("x"))
1142 .buttons(MessageBoxButtons::Ok)
1143 .show_again_checkbox_state(shared_state.clone())
1144 .show_again_checkbox(lit!("Don't show again"))
1145 .on_result(move |r, _ctx| {
1146 *captured_for_handler.borrow_mut() = Some(r);
1147 });
1148 let _content = present_and_lay_out(&mut tree, mb);
1149 shared_state.set(true);
1150 let ok_id = tree
1151 .find_by_label(&StandardButton::Ok.default_label().resolve_now())
1152 .unwrap();
1153 tree.dispatch_event(WidgetEvent::AccessAction {
1154 action: teksilo_core::accesskit::Action::Click,
1155 target: Some(ok_id),
1156 target_node: teksilo_core::accessibility::root_node_id(),
1157 data: None,
1158 });
1159 assert!(captured.borrow().unwrap().checkbox_checked);
1160 }
1161
1162 #[test]
1163 fn accessible_title_hint_propagates_to_container() {
1164 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1165 let mb = MessageBox::information(lit!("Title propagation test"))
1166 .text(lit!("Body"))
1167 .buttons(MessageBoxButtons::Ok);
1168 let content = present_and_lay_out(&mut tree, mb);
1169 let info = tree.accessibility_node(content);
1170 assert_eq!(info.role(), teksilo_core::accesskit::Role::Dialog);
1171 assert_eq!(info.name(), Some("Title propagation test"));
1172 }
1173
1174 #[test]
1175 fn standard_button_roles_classify_correctly() {
1176 assert_eq!(StandardButton::Ok.role(), ButtonRole::Accept);
1177 assert_eq!(StandardButton::Yes.role(), ButtonRole::Accept);
1178 assert_eq!(StandardButton::Save.role(), ButtonRole::Accept);
1179 assert_eq!(StandardButton::Cancel.role(), ButtonRole::Reject);
1180 assert_eq!(StandardButton::No.role(), ButtonRole::Reject);
1181 assert_eq!(StandardButton::Abort.role(), ButtonRole::Reject);
1182 assert_eq!(StandardButton::Discard.role(), ButtonRole::Destructive);
1183 assert_eq!(StandardButton::Help.role(), ButtonRole::Action);
1184 assert_eq!(StandardButton::Ignore.role(), ButtonRole::Action);
1185 }
1186
1187 #[test]
1200 fn a_long_details_pane_expands_and_keeps_the_dialog_intact() {
1201 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1202 let long: String = (1..=100)
1203 .map(|i| format!("line {i} of a very long detail dump\n"))
1204 .collect();
1205 let mb = MessageBox::critical(lit!("Could not open file"))
1206 .text(lit!("It went wrong."))
1207 .detailed_text(lit!(long))
1208 .buttons(MessageBoxButtons::Ok);
1209 let _content = present_and_lay_out(&mut tree, mb);
1210
1211 let toggle = tree
1212 .find_by_label(&teksilo_i18n::tr_widget!(messagebox_show_details()).resolve_now())
1213 .expect("the Show details toggle");
1214 tree.dispatch_event(WidgetEvent::AccessAction {
1215 action: teksilo_core::accesskit::Action::Click,
1216 target: Some(toggle),
1217 target_node: teksilo_core::accessibility::root_node_id(),
1218 data: None,
1219 });
1220 tree.layout(SizeProposal::exact(800.0, 600.0));
1221
1222 assert!(tree.find_by_label("Could not open file").is_some());
1224 assert!(
1225 tree.find_by_label(&StandardButton::Ok.default_label().resolve_now())
1226 .is_some(),
1227 "the button row must survive an expanded details pane"
1228 );
1229 }
1230
1231 #[test]
1232 fn escape_resolution_prefers_explicit_escape_button() {
1233 let state = State::new(Signal::new(false));
1234 *state.buttons.borrow_mut() = vec![StandardButton::Save, StandardButton::Discard];
1235 state.escape_button.set(Some(StandardButton::Discard));
1236 assert_eq!(state.resolve_escape_button(), Some(StandardButton::Discard));
1237 }
1238
1239 #[test]
1240 fn escape_resolution_falls_back_to_first_reject() {
1241 let state = State::new(Signal::new(false));
1242 *state.buttons.borrow_mut() = vec![
1243 StandardButton::Retry,
1244 StandardButton::Ignore,
1245 StandardButton::Abort,
1246 ];
1247 state.escape_button.set(None);
1248 assert_eq!(state.resolve_escape_button(), Some(StandardButton::Abort));
1249 }
1250
1251 #[test]
1252 fn escape_resolution_falls_back_to_last_when_no_reject() {
1253 let state = State::new(Signal::new(false));
1254 *state.buttons.borrow_mut() = vec![StandardButton::Ok, StandardButton::Help];
1255 state.escape_button.set(None);
1256 assert_eq!(state.resolve_escape_button(), Some(StandardButton::Help));
1257 }
1258}