1use std::cell::{Cell, RefCell};
115use std::rc::Rc;
116
117use teksilo_canvas::{Rect, Size, SizeProposal};
118use teksilo_core::accessibility::AccessNodeBuilder;
119use teksilo_core::action::Action;
120use teksilo_core::build_context::BuildContext;
121use teksilo_core::event::{Key, Modifiers};
122use teksilo_core::modal::{ModalCloseBehavior, ModalPresentation, ModalRequest};
123use teksilo_core::shortcut::{KeyStroke, Shortcut};
124use teksilo_core::signal::Signal;
125use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
126use teksilo_core::widget_builder::WidgetBuilder;
127use teksilo_core::widget_id::WidgetId;
128use teksilo_i18n::LocalizedString;
129use teksilo_tokens::{Alignment, HAlignment, InputTokens, TargetRole, VAlignment};
130
131use crate::accordion::Accordion;
132use crate::button::{Button, ButtonVariant};
133use crate::checkbox::Checkbox;
134use crate::dialog::ModalContainer;
135use crate::primitives::{Expand, HStack, Spacer, TextWidget, VStack};
136use crate::scroll_area::ScrollArea;
137use crate::severity_badge::{SeverityBadge, SeverityIconKind};
138use teksilo_core::styles::density::dp;
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
145pub enum MessageBoxSeverity {
146 #[default]
148 None,
149 Information,
151 Question,
153 Warning,
155 Critical,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum ButtonRole {
169 Accept,
171 Reject,
173 Destructive,
176 Action,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
185pub enum StandardButton {
186 Ok,
188 Cancel,
190 Close,
192 Yes,
194 No,
196 YesToAll,
198 NoToAll,
200 Save,
202 SaveAll,
204 Discard,
206 Apply,
208 Reset,
210 RestoreDefaults,
212 Abort,
214 Retry,
216 Ignore,
218 Open,
220 Help,
222}
223
224impl StandardButton {
225 pub fn role(self) -> ButtonRole {
229 match self {
230 Self::Ok
231 | Self::Yes
232 | Self::YesToAll
233 | Self::Save
234 | Self::SaveAll
235 | Self::Apply
236 | Self::Retry
237 | Self::Open => ButtonRole::Accept,
238 Self::Cancel | Self::Close | Self::No | Self::NoToAll | Self::Abort => {
239 ButtonRole::Reject
240 }
241 Self::Discard => ButtonRole::Destructive,
242 Self::Reset | Self::RestoreDefaults | Self::Ignore | Self::Help => ButtonRole::Action,
243 }
244 }
245
246 pub fn intent_name(self) -> &'static str {
251 match self {
252 Self::Ok => "messagebox.btn.ok",
253 Self::Cancel => "messagebox.btn.cancel",
254 Self::Close => "messagebox.btn.close",
255 Self::Yes => "messagebox.btn.yes",
256 Self::No => "messagebox.btn.no",
257 Self::YesToAll => "messagebox.btn.yes_to_all",
258 Self::NoToAll => "messagebox.btn.no_to_all",
259 Self::Save => "messagebox.btn.save",
260 Self::SaveAll => "messagebox.btn.save_all",
261 Self::Discard => "messagebox.btn.discard",
262 Self::Apply => "messagebox.btn.apply",
263 Self::Reset => "messagebox.btn.reset",
264 Self::RestoreDefaults => "messagebox.btn.restore_defaults",
265 Self::Abort => "messagebox.btn.abort",
266 Self::Retry => "messagebox.btn.retry",
267 Self::Ignore => "messagebox.btn.ignore",
268 Self::Open => "messagebox.btn.open",
269 Self::Help => "messagebox.btn.help",
270 }
271 }
272
273 pub fn default_label(self) -> LocalizedString {
276 match self {
277 Self::Ok => teksilo_i18n::tr_widget!(messagebox_btn_ok()),
278 Self::Cancel => teksilo_i18n::tr_widget!(messagebox_btn_cancel()),
279 Self::Close => teksilo_i18n::tr_widget!(messagebox_btn_close()),
280 Self::Yes => teksilo_i18n::tr_widget!(messagebox_btn_yes()),
281 Self::No => teksilo_i18n::tr_widget!(messagebox_btn_no()),
282 Self::YesToAll => teksilo_i18n::tr_widget!(messagebox_btn_yes_to_all()),
283 Self::NoToAll => teksilo_i18n::tr_widget!(messagebox_btn_no_to_all()),
284 Self::Save => teksilo_i18n::tr_widget!(messagebox_btn_save()),
285 Self::SaveAll => teksilo_i18n::tr_widget!(messagebox_btn_save_all()),
286 Self::Discard => teksilo_i18n::tr_widget!(messagebox_btn_discard()),
287 Self::Apply => teksilo_i18n::tr_widget!(messagebox_btn_apply()),
288 Self::Reset => teksilo_i18n::tr_widget!(messagebox_btn_reset()),
289 Self::RestoreDefaults => teksilo_i18n::tr_widget!(messagebox_btn_restore_defaults()),
290 Self::Abort => teksilo_i18n::tr_widget!(messagebox_btn_abort()),
291 Self::Retry => teksilo_i18n::tr_widget!(messagebox_btn_retry()),
292 Self::Ignore => teksilo_i18n::tr_widget!(messagebox_btn_ignore()),
293 Self::Open => teksilo_i18n::tr_widget!(messagebox_btn_open()),
294 Self::Help => teksilo_i18n::tr_widget!(messagebox_btn_help()),
295 }
296 }
297}
298
299#[derive(Debug, Clone)]
304pub struct MessageBoxButton {
305 pub kind: StandardButton,
308 pub label_override: Option<LocalizedString>,
312}
313
314impl MessageBoxButton {
315 pub fn standard(kind: StandardButton) -> Self {
317 Self {
318 kind,
319 label_override: None,
320 }
321 }
322
323 pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
325 self.label_override = Some(label.into());
326 self
327 }
328
329 fn resolved_label(&self) -> LocalizedString {
330 self.label_override
331 .clone()
332 .unwrap_or_else(|| self.kind.default_label())
333 }
334}
335
336impl From<StandardButton> for MessageBoxButton {
337 fn from(kind: StandardButton) -> Self {
338 Self::standard(kind)
339 }
340}
341
342#[derive(Debug, Clone)]
346pub enum MessageBoxButtons {
347 Ok,
349 OkCancel,
351 YesNo,
354 YesNoCancel,
357 SaveDiscardCancel,
359 RetryIgnoreAbort,
361 Custom(Vec<MessageBoxButton>),
365}
366
367impl MessageBoxButtons {
368 fn into_buttons(self) -> Vec<MessageBoxButton> {
369 match self {
370 Self::Ok => vec![StandardButton::Ok.into()],
371 Self::OkCancel => vec![StandardButton::Cancel.into(), StandardButton::Ok.into()],
372 Self::YesNo => vec![StandardButton::No.into(), StandardButton::Yes.into()],
373 Self::YesNoCancel => vec![
374 StandardButton::Cancel.into(),
375 StandardButton::No.into(),
376 StandardButton::Yes.into(),
377 ],
378 Self::SaveDiscardCancel => vec![
379 StandardButton::Discard.into(),
380 StandardButton::Cancel.into(),
381 StandardButton::Save.into(),
382 ],
383 Self::RetryIgnoreAbort => vec![
384 StandardButton::Abort.into(),
385 StandardButton::Ignore.into(),
386 StandardButton::Retry.into(),
387 ],
388 Self::Custom(items) => items,
389 }
390 }
391
392 fn preset_default(&self) -> Option<StandardButton> {
401 match self {
402 Self::Ok => Some(StandardButton::Ok),
403 Self::OkCancel => Some(StandardButton::Ok),
404 Self::YesNo => Some(StandardButton::No),
405 Self::YesNoCancel => Some(StandardButton::No),
406 Self::SaveDiscardCancel => Some(StandardButton::Save),
407 Self::RetryIgnoreAbort => Some(StandardButton::Retry),
408 Self::Custom(_) => None,
409 }
410 }
411
412 fn preset_escape(&self) -> Option<StandardButton> {
414 match self {
415 Self::Ok => Some(StandardButton::Ok),
416 Self::OkCancel => Some(StandardButton::Cancel),
417 Self::YesNo => Some(StandardButton::No),
418 Self::YesNoCancel => Some(StandardButton::Cancel),
419 Self::SaveDiscardCancel => Some(StandardButton::Cancel),
420 Self::RetryIgnoreAbort => Some(StandardButton::Abort),
421 Self::Custom(_) => None,
422 }
423 }
424}
425
426#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
438#[non_exhaustive]
439pub enum MessageBoxDismissal {
440 Button,
442 Escape,
444 ClickOutside,
446 Programmatic,
451}
452
453#[derive(Debug, Clone, Copy)]
455pub struct MessageBoxResult {
456 pub button: StandardButton,
460 pub checkbox_checked: bool,
465 pub dismissal: MessageBoxDismissal,
467}
468
469impl From<teksilo_core::overlay::DismissReason> for MessageBoxDismissal {
470 fn from(reason: teksilo_core::overlay::DismissReason) -> Self {
471 use teksilo_core::overlay::DismissReason as R;
472 match reason {
473 R::Escape => Self::Escape,
474 R::OutsidePress => Self::ClickOutside,
475 _ => Self::Programmatic,
478 }
479 }
480}
481
482impl MessageBoxResult {
483 pub fn was_dismissed(&self) -> bool {
488 self.dismissal != MessageBoxDismissal::Button
489 }
490}
491
492const SEVERITY_ICON_SIZE: f32 = 48.0;
493
494fn severity_icon_size(tokens: &InputTokens) -> f32 {
497 dp(SEVERITY_ICON_SIZE, TargetRole::Target, tokens)
498}
499
500const DETAILS_MAX_HEIGHT: f32 = 220.0;
508
509const DEFAULT_INTENT_NAME: &str = "messagebox.accept_default";
510const ESCAPE_INTENT_NAME: &str = "messagebox.escape";
511
512fn severity_icon_kind(severity: MessageBoxSeverity) -> Option<SeverityIconKind> {
515 match severity {
516 MessageBoxSeverity::None => None,
517 MessageBoxSeverity::Information => Some(SeverityIconKind::Info),
518 MessageBoxSeverity::Question => Some(SeverityIconKind::Question),
519 MessageBoxSeverity::Warning => Some(SeverityIconKind::Warning),
520 MessageBoxSeverity::Critical => Some(SeverityIconKind::Error),
521 }
522}
523
524struct State {
531 on_result: RefCell<Option<Box<dyn Fn(MessageBoxResult, &mut EventContext)>>>,
532 checkbox: Signal<bool>,
533 escape_button: Cell<Option<StandardButton>>,
534 default_button: Cell<Option<StandardButton>>,
535 buttons: RefCell<Vec<StandardButton>>,
539 button_ids: RefCell<Vec<(WidgetId, StandardButton)>>,
543 fired: Cell<bool>,
546}
547
548impl State {
549 fn new(checkbox: Signal<bool>) -> Rc<Self> {
550 Rc::new(Self {
551 on_result: RefCell::new(None),
552 checkbox,
553 escape_button: Cell::new(None),
554 default_button: Cell::new(None),
555 buttons: RefCell::new(Vec::new()),
556 button_ids: RefCell::new(Vec::new()),
557 fired: Cell::new(false),
558 })
559 }
560
561 fn button_for(&self, id: WidgetId) -> Option<StandardButton> {
563 self.button_ids
564 .borrow()
565 .iter()
566 .find(|(btn, _)| *btn == id)
567 .map(|(_, kind)| *kind)
568 }
569
570 fn fire(&self, button: StandardButton, dismissal: MessageBoxDismissal, ctx: &mut EventContext) {
572 if self.report(button, dismissal, ctx) {
573 ctx.dismiss_modal();
574 }
575 }
576
577 fn report(
586 &self,
587 button: StandardButton,
588 dismissal: MessageBoxDismissal,
589 ctx: &mut EventContext,
590 ) -> bool {
591 if self.fired.replace(true) {
592 return false;
593 }
594 let result = MessageBoxResult {
595 button,
596 checkbox_checked: self.checkbox.get(),
597 dismissal,
598 };
599 if let Some(handler) = self.on_result.borrow().as_ref() {
600 handler(result, ctx);
601 }
602 true
603 }
604
605 fn resolve_escape_button(&self) -> Option<StandardButton> {
606 if let Some(btn) = self.escape_button.get() {
607 return Some(btn);
608 }
609 let buttons = self.buttons.borrow();
610 if let Some(btn) = buttons.iter().find(|b| b.role() == ButtonRole::Reject) {
611 return Some(*btn);
612 }
613 if buttons.contains(&StandardButton::Cancel) {
614 return Some(StandardButton::Cancel);
615 }
616 buttons.last().copied()
617 }
618}
619
620pub struct MessageBox {
630 severity: MessageBoxSeverity,
631 title: LocalizedString,
632 text: Option<LocalizedString>,
633 informative_text: Option<LocalizedString>,
634 detailed_text: Option<LocalizedString>,
635 buttons_config: Option<MessageBoxButtons>,
636 extra_buttons: Vec<MessageBoxButton>,
637 default_button: Option<StandardButton>,
638 escape_button: Option<StandardButton>,
639 show_again_label: Option<LocalizedString>,
640 show_again_state: Option<Signal<bool>>,
641 title_alignment: HAlignment,
642 button_alignment: HAlignment,
643 on_result: Option<Box<dyn Fn(MessageBoxResult, &mut EventContext)>>,
644 default_button_id: Cell<Option<WidgetId>>,
645 root_child_id: Option<WidgetId>,
646 state: Option<Rc<State>>,
647 state_slot: Option<Rc<RefCell<Option<Rc<State>>>>>,
652}
653
654impl std::fmt::Debug for MessageBox {
655 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
656 f.debug_struct("MessageBox")
657 .field("severity", &self.severity)
658 .field("title", &self.title)
659 .field("text", &self.text)
660 .field("informative_text", &self.informative_text)
661 .field("detailed_text", &self.detailed_text)
662 .field("default_button", &self.default_button)
663 .field("escape_button", &self.escape_button)
664 .finish()
665 }
666}
667
668impl MessageBox {
669 fn new_with_severity(severity: MessageBoxSeverity, title: impl Into<LocalizedString>) -> Self {
670 let title = title.into();
671 Self {
672 severity,
673 title,
674 text: None,
675 informative_text: None,
676 detailed_text: None,
677 buttons_config: None,
678 extra_buttons: Vec::new(),
679 default_button: None,
680 escape_button: None,
681 show_again_label: None,
682 show_again_state: None,
683 title_alignment: HAlignment::Leading,
684 button_alignment: HAlignment::Trailing,
685 on_result: None,
686 default_button_id: Cell::new(None),
687 root_child_id: None,
688 state: None,
689 state_slot: None,
690 }
691 }
692
693 pub fn information(title: impl Into<LocalizedString>) -> Self {
695 Self::new_with_severity(MessageBoxSeverity::Information, title)
696 }
697
698 pub fn warning(title: impl Into<LocalizedString>) -> Self {
700 Self::new_with_severity(MessageBoxSeverity::Warning, title)
701 }
702
703 pub fn critical(title: impl Into<LocalizedString>) -> Self {
707 Self::new_with_severity(MessageBoxSeverity::Critical, title)
708 }
709
710 pub fn question(title: impl Into<LocalizedString>) -> Self {
713 Self::new_with_severity(MessageBoxSeverity::Question, title)
714 }
715
716 pub fn plain(title: impl Into<LocalizedString>) -> Self {
718 Self::new_with_severity(MessageBoxSeverity::None, title)
719 }
720
721 pub fn text(mut self, text: impl Into<LocalizedString>) -> Self {
725 self.text = Some(text.into());
726 self
727 }
728
729 pub fn informative_text(mut self, text: impl Into<LocalizedString>) -> Self {
733 self.informative_text = Some(text.into());
734 self
735 }
736
737 pub fn detailed_text(mut self, text: impl Into<LocalizedString>) -> Self {
741 self.detailed_text = Some(text.into());
742 self
743 }
744
745 pub fn buttons(mut self, preset: MessageBoxButtons) -> Self {
750 if self.default_button.is_none() {
751 self.default_button = preset.preset_default();
752 }
753 if self.escape_button.is_none() {
754 self.escape_button = preset.preset_escape();
755 }
756 self.buttons_config = Some(preset);
757 self
758 }
759
760 pub fn add_button(mut self, button: impl Into<MessageBoxButton>) -> Self {
764 self.extra_buttons.push(button.into());
765 self
766 }
767
768 pub fn add_buttons(
773 self,
774 buttons: impl IntoIterator<Item = impl Into<MessageBoxButton>>,
775 ) -> Self {
776 buttons.into_iter().fold(self, Self::add_button)
777 }
778
779 pub fn default_button(mut self, which: StandardButton) -> Self {
783 self.default_button = Some(which);
784 self
785 }
786
787 pub fn escape_button(mut self, which: StandardButton) -> Self {
790 self.escape_button = Some(which);
791 self
792 }
793
794 pub fn show_again_checkbox(mut self, label: impl Into<LocalizedString>) -> Self {
800 self.show_again_label = Some(label.into());
801 self
802 }
803
804 pub fn show_again_checkbox_state(mut self, signal: Signal<bool>) -> Self {
808 self.show_again_state = Some(signal);
809 self
810 }
811
812 pub fn title_alignment(mut self, alignment: HAlignment) -> Self {
817 self.title_alignment = alignment;
818 self
819 }
820
821 pub fn button_alignment(mut self, alignment: HAlignment) -> Self {
826 self.button_alignment = alignment;
827 self
828 }
829
830 pub fn on_result(mut self, f: impl Fn(MessageBoxResult, &mut EventContext) + 'static) -> Self {
834 self.on_result = Some(Box::new(f));
835 self
836 }
837
838 pub fn present(mut self, ctx: &mut EventContext) {
842 let title = self.title.clone();
843 let close_behavior = if self.severity == MessageBoxSeverity::Critical {
844 ModalCloseBehavior::EscapeKey
845 } else {
846 ModalCloseBehavior::EscapeOrClickOutside
847 };
848
849 let slot: Rc<RefCell<Option<Rc<State>>>> = Rc::new(RefCell::new(None));
856 self.state_slot = Some(slot.clone());
857 let on_dismiss: teksilo_core::overlay::OverlayDismissCallback =
858 Rc::new(move |reason, ctx: &mut EventContext| {
859 let Some(state) = slot.borrow().clone() else {
860 return;
861 };
862 let Some(button) = state.resolve_escape_button() else {
863 return;
864 };
865 state.report(button, MessageBoxDismissal::from(reason), ctx);
869 });
870
871 let dialog_title = self.title.clone();
872 let mut inner = Some(self);
873 ctx.present_modal(
874 ModalRequest::deferred(move |tree| {
875 let mb = inner
876 .take()
877 .expect("MessageBox present closure called twice");
878 tree.add(ModalContainer::new(mb).title(dialog_title.clone()))
879 })
880 .presentation(ModalPresentation::Auto)
881 .close_behavior(close_behavior)
882 .title(title)
883 .size(460, 140)
884 .on_dismiss(on_dismiss),
885 );
886 }
887
888 fn resolve_buttons(&mut self) -> Vec<MessageBoxButton> {
889 let mut resolved = self
890 .buttons_config
891 .clone()
892 .map(|b| b.into_buttons())
893 .unwrap_or_default();
894 resolved.extend(self.extra_buttons.iter().cloned());
895 if resolved.is_empty() {
896 resolved.push(StandardButton::Ok.into());
897 if self.default_button.is_none() {
898 self.default_button = Some(StandardButton::Ok);
899 }
900 if self.escape_button.is_none() {
901 self.escape_button = Some(StandardButton::Ok);
902 }
903 }
904 resolved
905 }
906}
907
908impl Widget for MessageBox {
909 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
910 let theme = ctx.theme().clone();
911
912 let checkbox_signal = self
913 .show_again_state
914 .clone()
915 .unwrap_or_else(|| ctx.signal(false));
916 let state = State::new(checkbox_signal.clone());
917 *state.on_result.borrow_mut() = self.on_result.take();
918
919 let buttons = self.resolve_buttons();
920 *state.buttons.borrow_mut() = buttons.iter().map(|b| b.kind).collect();
921 state.default_button.set(self.default_button);
922 state.escape_button.set(self.escape_button);
923
924 let mut header_text_stack = VStack::new().spacing(6.0);
925 let title_text = TextWidget::new(self.title.clone())
928 .style(theme.typography.body_bold.clone())
929 .color(theme.colors.text_primary)
930 .a11y_hidden();
931 header_text_stack =
932 if self.title_alignment == HAlignment::Leading {
933 header_text_stack.child(title_text)
934 } else {
935 header_text_stack.child(Expand::horizontal().child(title_text).align_child(
939 Alignment {
940 horizontal: self.title_alignment,
941 vertical: VAlignment::Top,
942 },
943 ))
944 };
945 if let Some(text) = self.text.clone() {
946 header_text_stack = header_text_stack.child(
947 TextWidget::new(text)
948 .style(theme.typography.body.clone())
949 .color(theme.colors.text_primary),
950 );
951 }
952 if let Some(info) = self.informative_text.clone() {
953 header_text_stack = header_text_stack.child(
954 TextWidget::new(info)
955 .style(theme.typography.body.clone())
956 .color(theme.colors.text_secondary),
957 );
958 }
959
960 let header: Box<dyn Widget> = if let Some(kind) = severity_icon_kind(self.severity) {
961 Box::new(
967 HStack::new()
968 .spacing(16.0)
969 .alignment(VAlignment::Top)
970 .child(SeverityBadge::new(
971 kind,
972 severity_icon_size(&ctx.theme().input),
973 ))
974 .child(Expand::horizontal().child(header_text_stack)),
975 )
976 } else {
977 Box::new(header_text_stack)
978 };
979
980 let detailed_child: Option<Box<dyn Widget>> = self.detailed_text.clone().map(|text| {
981 let expanded = ctx.signal(false);
982 let label: LocalizedString = teksilo_i18n::tr_widget!(messagebox_show_details());
983 let body = TextWidget::new(text)
984 .style(theme.typography.small.clone())
985 .color(theme.colors.text_secondary);
986 let scroller = ScrollArea::new()
992 .child(body)
993 .preferred_height(DETAILS_MAX_HEIGHT);
994 let accordion: Box<dyn Widget> =
995 Box::new(Accordion::new(label, expanded).content(scroller));
996 accordion
997 });
998
999 let checkbox_child: Option<Box<dyn Widget>> = self.show_again_label.clone().map(|label| {
1000 let cb: Box<dyn Widget> = Box::new(Checkbox::new(checkbox_signal.clone()).label(label));
1001 cb
1002 });
1003
1004 let button_alignment = self.button_alignment;
1007 let mut footer = HStack::new().spacing(8.0);
1008 if button_alignment != HAlignment::Leading {
1009 footer = footer.child(Spacer::new());
1010 }
1011 state.button_ids.borrow_mut().clear();
1012 for button_cfg in &buttons {
1013 let kind = button_cfg.kind;
1014 let label = button_cfg.resolved_label();
1015 let variant = if Some(kind) == self.default_button {
1016 ButtonVariant::Filled
1017 } else {
1018 ButtonVariant::Plain
1019 };
1020 let state_for_btn = state.clone();
1021 let btn_id = ctx.add(
1022 Button::new(label)
1023 .variant(variant)
1024 .on_activate_fn(move |ctx| {
1025 state_for_btn.fire(kind, MessageBoxDismissal::Button, ctx);
1026 }),
1027 );
1028 if Some(kind) == self.default_button {
1029 self.default_button_id.set(Some(btn_id));
1030 }
1031 state.button_ids.borrow_mut().push((btn_id, kind));
1032 footer = footer.child(btn_id);
1033 }
1034 if button_alignment != HAlignment::Trailing {
1035 footer = footer.child(Spacer::new());
1036 }
1037
1038 let mut stack = VStack::new().spacing(16.0);
1039 stack = stack.child(ctx.add_boxed(header));
1040 if let Some(det) = detailed_child {
1041 stack = stack.child(ctx.add_boxed(det));
1042 }
1043 if let Some(cb) = checkbox_child {
1044 stack = stack.child(ctx.add_boxed(cb));
1045 }
1046 stack = stack.child(ctx.add(Spacer::new()));
1049 let footer_id = ctx.add(footer);
1050 stack = stack.child(footer_id);
1051
1052 let root = ctx.add(
1070 stack
1071 .access_role(teksilo_core::accesskit::Role::GenericContainer)
1072 .access_live(teksilo_core::accesskit::Live::Off),
1073 );
1074 self.root_child_id = Some(root);
1075
1076 {
1077 let state_enter = state.clone();
1078 ctx.register_action(
1079 Action::new(DEFAULT_INTENT_NAME).on_invoke(move |_intent, ctx| {
1080 let focused = ctx.focused().and_then(|id| state_enter.button_for(id));
1097 if let Some(kind) = focused.or_else(|| state_enter.default_button.get()) {
1098 state_enter.fire(kind, MessageBoxDismissal::Button, ctx);
1099 }
1100 }),
1101 );
1102 ctx.register_shortcut(
1103 Shortcut::new(DEFAULT_INTENT_NAME)
1104 .primary(KeyStroke::new(Key::Enter, Modifiers::NONE))
1105 .build(),
1106 );
1107 }
1108 {
1109 let state_escape = state.clone();
1110 ctx.register_action(
1111 Action::new(ESCAPE_INTENT_NAME).on_invoke(move |_intent, ctx| {
1112 if let Some(kind) = state_escape.resolve_escape_button() {
1113 state_escape.fire(kind, MessageBoxDismissal::Escape, ctx);
1114 } else {
1115 ctx.dismiss_modal();
1116 }
1117 }),
1118 );
1119 ctx.register_shortcut(
1120 Shortcut::new(ESCAPE_INTENT_NAME)
1121 .primary(KeyStroke::new(Key::Escape, Modifiers::NONE))
1122 .build(),
1123 );
1124 }
1125
1126 if let Some(slot) = &self.state_slot {
1127 *slot.borrow_mut() = Some(state.clone());
1128 }
1129 self.state = Some(state);
1130 vec![root]
1131 }
1132
1133 fn layout_response(
1134 &self,
1135 proposal: SizeProposal,
1136 ctx: &LayoutContext,
1137 ) -> teksilo_core::widget::LayoutResponse {
1138 let child = self
1146 .root_child_id
1147 .and_then(|id| ctx.child_size(id, proposal))
1148 .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
1149 Size::new(child.width.max(460.0), child.height.max(140.0)).into()
1150 }
1151
1152 fn place_children(
1153 &self,
1154 bounds: Rect,
1155 _proposal: SizeProposal,
1156 children: &mut [WidgetPlacement],
1157 _ctx: &LayoutContext,
1158 ) {
1159 for child in children.iter_mut() {
1160 child.origin = bounds.origin();
1161 child.size = bounds.size();
1162 }
1163 }
1164
1165 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
1166
1167 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1168 builder.set_role(teksilo_core::accesskit::Role::AlertDialog);
1169 builder.set_name(self.title.clone());
1170 if let Some(description) = self.accessible_description() {
1171 builder.set_description(description);
1172 }
1173 builder.set_modal();
1174 builder.set_live(teksilo_core::accesskit::Live::Assertive);
1175 builder.add_action(teksilo_core::accesskit::Action::Focus);
1176 }
1177
1178 fn accessible_title_hint(&self) -> Option<String> {
1179 Some(self.title.resolve_now())
1180 }
1181
1182 fn initial_focus_hint(&self) -> Option<WidgetId> {
1183 self.default_button_id.get()
1184 }
1185
1186 fn children(&self) -> Vec<WidgetId> {
1187 self.root_child_id.into_iter().collect()
1188 }
1189}
1190
1191impl MessageBox {
1192 fn accessible_description(&self) -> Option<String> {
1193 match (
1194 self.text.as_ref().map(|t| t.resolve_now()),
1195 self.informative_text.as_ref().map(|i| i.resolve_now()),
1196 ) {
1197 (None, None) => None,
1198 (Some(t), None) => Some(t),
1199 (None, Some(i)) => Some(i),
1200 (Some(t), Some(i)) => Some(format!("{t}\n{i}")),
1201 }
1202 }
1203}
1204
1205pub trait EventContextMessageBoxExt {
1209 fn present_message_box(&mut self, mb: MessageBox);
1211}
1212
1213impl EventContextMessageBoxExt for EventContext<'_> {
1214 fn present_message_box(&mut self, mb: MessageBox) {
1215 mb.present(self);
1216 }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221 use super::*;
1222 use teksilo_core::ModalContent;
1223 use teksilo_core::event::WidgetEvent;
1224 use teksilo_core::widget_tree::WidgetTree;
1225 use teksilo_i18n::lit;
1226
1227 fn present_and_lay_out(tree: &mut WidgetTree, mb: MessageBox) -> WidgetId {
1231 use crate::button::Button as Btn;
1232 let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(mb)));
1233 let mb_for_closure = mb_cell.clone();
1234 let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
1235 if let Some(mb) = mb_for_closure.borrow_mut().take() {
1236 mb.present(ctx);
1237 }
1238 }));
1239 tree.layout(SizeProposal::exact(800.0, 600.0));
1240 tree.dispatch_event(WidgetEvent::AccessAction {
1241 action: teksilo_core::accesskit::Action::Click,
1242 target: Some(trigger),
1243 target_node: teksilo_core::accessibility::root_node_id(),
1244 data: None,
1245 });
1246 let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1247 let content_id = match request.content {
1248 ModalContent::Deferred(builder) => builder(tree),
1249 ModalContent::ExistingWidget(_) => panic!("MessageBox must use deferred content"),
1250 };
1251 tree.layout(SizeProposal::exact(800.0, 600.0));
1252 let focus_target = request
1253 .focus_target
1254 .filter(|id| tree.is_active(*id) && tree.is_descendant_of(*id, content_id))
1255 .or_else(|| tree.widget_initial_focus_hint(content_id))
1256 .or_else(|| tree.first_focusable_descendant(content_id));
1257 if let Some(id) = focus_target {
1258 tree.focus(id);
1259 }
1260 content_id
1261 }
1262
1263 struct Placement {
1266 message_box: Rect,
1267 title: Rect,
1268 buttons_left: f32,
1269 buttons_right: f32,
1270 }
1271
1272 fn placement_of(tree: &WidgetTree, content: WidgetId) -> Placement {
1273 let panel = tree.children(content).first().copied().unwrap();
1274 let mb_id = tree.children(panel).first().copied().unwrap();
1275 let mut stack = vec![mb_id];
1276 let mut title_rect = None;
1277 let (mut left, mut right) = (f32::MAX, f32::MIN);
1278 while let Some(id) = stack.pop() {
1279 stack.extend(tree.children(id));
1280 let is = |name: &str| tree.widget_type_name(id).is_some_and(|t| t.ends_with(name));
1281 if is("TextWidget") {
1284 let b = tree.bounds(id);
1285 if title_rect.is_none_or(|t: Rect| b.y < t.y) {
1286 title_rect = Some(b);
1287 }
1288 }
1289 if is("Button") {
1290 let b = tree.bounds(id);
1291 left = left.min(b.x);
1292 right = right.max(b.x + b.width);
1293 }
1294 }
1295 Placement {
1296 message_box: tree.bounds(mb_id),
1297 title: title_rect.expect("title widget"),
1298 buttons_left: left,
1299 buttons_right: right,
1300 }
1301 }
1302
1303 fn placement_with(
1304 title_alignment: Option<HAlignment>,
1305 button_alignment: Option<HAlignment>,
1306 ) -> Placement {
1307 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1308 let mut mb = MessageBox::plain(lit!("ZqTitle"))
1309 .text(lit!(
1310 "A body line that is long enough to make the column wide."
1311 ))
1312 .buttons(MessageBoxButtons::OkCancel);
1313 if let Some(a) = title_alignment {
1314 mb = mb.title_alignment(a);
1315 }
1316 if let Some(a) = button_alignment {
1317 mb = mb.button_alignment(a);
1318 }
1319 let content = present_and_lay_out(&mut tree, mb);
1320 placement_of(&tree, content)
1321 }
1322
1323 #[test]
1324 fn default_alignment_is_leading_title_and_trailing_buttons() {
1325 let p = placement_with(None, None);
1326 let m = p.message_box;
1327 assert!(
1328 (p.title.x - m.x).abs() < 40.0,
1329 "title leads: {:?} in {m:?}",
1330 p.title
1331 );
1332 assert!(
1333 (m.x + m.width - p.buttons_right).abs() < 40.0,
1334 "buttons trail: right edge {} in {m:?}",
1335 p.buttons_right
1336 );
1337 assert!(p.buttons_left > m.x + m.width / 2.0 - 100.0 || m.width < 300.0);
1338 }
1339
1340 #[test]
1341 fn centred_title_and_buttons_sit_in_the_middle() {
1342 let p = placement_with(Some(HAlignment::Center), Some(HAlignment::Center));
1343 let m = p.message_box;
1344 let mid = m.x + m.width / 2.0;
1345 let title_mid = p.title.x + p.title.width / 2.0;
1346 assert!(
1347 (title_mid - mid).abs() < 12.0,
1348 "title centre {title_mid} vs {mid}"
1349 );
1350 let buttons_mid = (p.buttons_left + p.buttons_right) / 2.0;
1351 assert!(
1352 (buttons_mid - mid).abs() < 12.0,
1353 "buttons centre {buttons_mid} vs {mid}"
1354 );
1355 }
1356
1357 #[test]
1358 fn trailing_title_and_leading_buttons_swap_edges() {
1359 let p = placement_with(Some(HAlignment::Trailing), Some(HAlignment::Leading));
1360 let m = p.message_box;
1361 let default = placement_with(None, None);
1362 assert!(
1363 p.title.x > default.title.x + 20.0,
1364 "title moved to the trailing side"
1365 );
1366 assert!(
1367 p.buttons_left < default.buttons_left - 20.0,
1368 "buttons moved to the leading side"
1369 );
1370 assert!(
1371 p.buttons_left < m.x + 60.0,
1372 "buttons lead: {} in {m:?}",
1373 p.buttons_left
1374 );
1375 }
1376
1377 #[test]
1378 fn present_queues_modal_request() {
1379 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1380 let mb = MessageBox::information(lit!("t"))
1381 .text(lit!("x"))
1382 .buttons(MessageBoxButtons::Ok);
1383 let _content = present_and_lay_out(&mut tree, mb);
1384 assert!(tree.find_by_label("t").is_some());
1385 }
1386
1387 #[test]
1388 fn critical_uses_escape_only_close_behavior() {
1389 use crate::button::Button as Btn;
1390 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1391 let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(
1392 MessageBox::critical(lit!("Fatal"))
1393 .text(lit!("Boom"))
1394 .buttons(MessageBoxButtons::Ok),
1395 )));
1396 let mb_for_closure = mb_cell.clone();
1397 let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
1398 if let Some(mb) = mb_for_closure.borrow_mut().take() {
1399 mb.present(ctx);
1400 }
1401 }));
1402 tree.layout(SizeProposal::exact(800.0, 600.0));
1403 tree.dispatch_event(WidgetEvent::AccessAction {
1404 action: teksilo_core::accesskit::Action::Click,
1405 target: Some(trigger),
1406 target_node: teksilo_core::accessibility::root_node_id(),
1407 data: None,
1408 });
1409 let request = tree.drain_pending_modal_requests().pop().unwrap().request;
1410 assert_eq!(request.close_behavior, ModalCloseBehavior::EscapeKey);
1411 }
1412
1413 #[test]
1414 fn alert_dialog_role_exposed() {
1415 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1416 let mb = MessageBox::warning(lit!("Title"))
1417 .text(lit!("Body"))
1418 .buttons(MessageBoxButtons::Ok);
1419 let content = present_and_lay_out(&mut tree, mb);
1420 let panel = tree.children(content).first().copied().unwrap();
1424 let mb_id = tree.children(panel).first().copied().unwrap();
1425 let info = tree.accessibility_node(mb_id);
1426 assert_eq!(info.role(), teksilo_core::accesskit::Role::AlertDialog);
1427 assert_eq!(info.name(), Some("Title"));
1428 }
1429
1430 #[test]
1434 fn nothing_inside_the_box_is_live_on_its_own() {
1435 use teksilo_core::accesskit::{Live, Role};
1436 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1437 let mb = MessageBox::warning(lit!("Supprimer « Vacances » ?"))
1438 .text(lit!("Cet évènement sera retiré de votre liste."))
1439 .buttons(MessageBoxButtons::YesNo)
1440 .default_button(StandardButton::No);
1441 let _content = present_and_lay_out(&mut tree, mb);
1442 let update = tree.sync_accessibility();
1443
1444 let consumer = accesskit_consumer::Tree::new(update, true);
1445 let state = consumer.state();
1446 let mut stack = vec![state.root()];
1447 let mut the_box = None;
1448 let mut inside = Vec::new();
1449 while let Some(node) = stack.pop() {
1450 if node.role() == Role::AlertDialog {
1451 the_box = Some((node.label(), node.live()));
1452 let mut below: Vec<_> = node.children().collect();
1453 while let Some(child) = below.pop() {
1454 inside.push((child.role(), child.live()));
1455 below.extend(child.children());
1456 }
1457 }
1458 stack.extend(node.children());
1459 }
1460 assert_eq!(
1461 the_box,
1462 Some((
1463 Some("Supprimer « Vacances » ?".to_string()),
1464 Live::Assertive
1465 )),
1466 "the box stays an assertive live region named by its question"
1467 );
1468 assert!(
1469 inside.iter().any(|(role, _)| *role == Role::Button),
1470 "the walk must reach the buttons for this to say anything"
1471 );
1472 let live_inside: Vec<_> = inside
1473 .iter()
1474 .filter(|(_, live)| *live != Live::Off)
1475 .collect();
1476 assert!(
1477 live_inside.is_empty(),
1478 "live inside the box: {live_inside:?}"
1479 );
1480 }
1481
1482 #[test]
1488 fn opening_the_box_does_not_announce_its_buttons() {
1489 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1490 tree.layout(SizeProposal::exact(800.0, 600.0));
1491 let _ = tree.sync_accessibility();
1492 let seen = tree.announcements_since(0).last().map_or(0, |a| a.seq);
1493 let mb = MessageBox::warning(lit!("Supprimer « Vacances » ?"))
1494 .text(lit!("Cet évènement sera retiré de votre liste."))
1495 .buttons(MessageBoxButtons::YesNo)
1496 .default_button(StandardButton::No);
1497 let _content = present_and_lay_out(&mut tree, mb);
1498 let _ = tree.sync_accessibility();
1499 let heard: Vec<String> = tree
1500 .announcements_since(seen)
1501 .into_iter()
1502 .map(|a| a.text)
1503 .collect();
1504 let yes = StandardButton::Yes.default_label().resolve_now();
1505 let no = StandardButton::No.default_label().resolve_now();
1506 assert!(
1507 !heard
1508 .iter()
1509 .any(|t| *t == yes || *t == no || t.starts_with("Cet évènement")),
1510 "heard {heard:?}"
1511 );
1512 assert_eq!(
1513 heard,
1514 ["Supprimer « Vacances » ?"],
1515 "the question, once, and nothing else"
1516 );
1517 }
1518
1519 #[test]
1520 fn title_label_is_hidden_but_body_label_survives() {
1521 use teksilo_core::accessibility::widget_id_to_node_id;
1528 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1529 let mb = MessageBox::warning(lit!("Title"))
1530 .text(lit!("Body"))
1531 .buttons(MessageBoxButtons::Ok);
1532 let content = present_and_lay_out(&mut tree, mb);
1533 let panel = tree.children(content).first().copied().unwrap();
1534 let mb_id = tree.children(panel).first().copied().unwrap();
1535
1536 let update = tree.sync_accessibility();
1537
1538 let mb_node_id = widget_id_to_node_id(mb_id);
1539 let mb_node = update
1540 .nodes
1541 .iter()
1542 .find(|(nid, _)| *nid == mb_node_id)
1543 .map(|(_, node)| node)
1544 .expect("MessageBox node");
1545 assert_eq!(mb_node.role(), teksilo_core::accesskit::Role::AlertDialog);
1546 assert_eq!(
1547 mb_node.label(),
1548 Some("Title"),
1549 "hiding the title label must not take the dialog's own name away with it",
1550 );
1551
1552 assert!(
1553 !update.nodes.iter().any(|(_, node)| node.role()
1554 == teksilo_core::accesskit::Role::Label
1555 && teksilo_core::accessibility::announced_text(node) == Some("Title")),
1556 "the title Label must not survive as a duplicate-named node",
1557 );
1558
1559 assert!(
1560 update.nodes.iter().any(|(_, node)| node.role()
1561 == teksilo_core::accesskit::Role::Label
1562 && teksilo_core::accessibility::announced_text(node) == Some("Body")),
1563 "the body label is not a name duplicate and must stay visible",
1564 );
1565 }
1566
1567 #[test]
1568 fn ok_button_fires_result_with_correct_kind() {
1569 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1570 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1571 let captured_for_handler = captured.clone();
1572 let mb = MessageBox::information(lit!("t"))
1573 .text(lit!("x"))
1574 .buttons(MessageBoxButtons::Ok)
1575 .on_result(move |r, _ctx| {
1576 *captured_for_handler.borrow_mut() = Some(r);
1577 });
1578 let _content = present_and_lay_out(&mut tree, mb);
1579 let ok_id = tree
1580 .find_by_label(&StandardButton::Ok.default_label().resolve_now())
1581 .unwrap();
1582 tree.dispatch_event(WidgetEvent::AccessAction {
1583 action: teksilo_core::accesskit::Action::Click,
1584 target: Some(ok_id),
1585 target_node: teksilo_core::accessibility::root_node_id(),
1586 data: None,
1587 });
1588 let result = captured.borrow().expect("result must be captured");
1589 assert_eq!(result.button, StandardButton::Ok);
1590 assert!(!result.checkbox_checked);
1591 assert_eq!(result.dismissal, MessageBoxDismissal::Button);
1592 }
1593
1594 #[test]
1595 fn default_button_is_focused_on_open() {
1596 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1597 let mb = MessageBox::question(lit!("t"))
1598 .text(lit!("x"))
1599 .buttons(MessageBoxButtons::YesNoCancel)
1600 .default_button(StandardButton::No);
1601 let _content = present_and_lay_out(&mut tree, mb);
1602 let no_id = tree
1603 .find_by_label(&StandardButton::No.default_label().resolve_now())
1604 .unwrap();
1605 assert_eq!(tree.focused(), Some(no_id));
1606 }
1607
1608 #[test]
1616 fn a_yes_no_box_defaults_to_no_so_enter_cannot_destroy() {
1617 for buttons in [MessageBoxButtons::YesNo, MessageBoxButtons::YesNoCancel] {
1618 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1619 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1620 let captured_for_handler = captured.clone();
1621 let mb = MessageBox::question(lit!("t"))
1622 .text(lit!("x"))
1623 .buttons(buttons.clone())
1624 .on_result(move |r, _ctx| {
1625 *captured_for_handler.borrow_mut() = Some(r);
1626 });
1627 let _content = present_and_lay_out(&mut tree, mb);
1628
1629 let no_id = tree
1630 .find_by_label(&StandardButton::No.default_label().resolve_now())
1631 .unwrap();
1632 assert_eq!(
1633 tree.focused(),
1634 Some(no_id),
1635 "{buttons:?} must open with No focused"
1636 );
1637
1638 tree.press_key(Key::Enter, Modifiers::NONE);
1639 let result = captured.borrow().expect("result must be captured");
1640 assert_eq!(
1641 result.button,
1642 StandardButton::No,
1643 "{buttons:?}: Enter on an unread confirmation must not answer Yes"
1644 );
1645 }
1646 }
1647
1648 #[test]
1652 fn yes_no_cancel_keeps_cancel_as_the_escape_button() {
1653 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1654 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1655 let captured_for_handler = captured.clone();
1656 let mb = MessageBox::question(lit!("t"))
1657 .text(lit!("x"))
1658 .buttons(MessageBoxButtons::YesNoCancel)
1659 .on_result(move |r, _ctx| {
1660 *captured_for_handler.borrow_mut() = Some(r);
1661 });
1662 let _content = present_and_lay_out(&mut tree, mb);
1663 tree.press_key(Key::Escape, Modifiers::NONE);
1664 let result = captured.borrow().expect("result must be captured");
1665 assert_eq!(result.button, StandardButton::Cancel);
1666 assert_eq!(result.dismissal, MessageBoxDismissal::Escape);
1667 }
1668
1669 #[test]
1672 fn default_button_overrides_the_preset() {
1673 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1674 let mb = MessageBox::question(lit!("t"))
1675 .text(lit!("x"))
1676 .buttons(MessageBoxButtons::YesNo)
1677 .default_button(StandardButton::Yes);
1678 let _content = present_and_lay_out(&mut tree, mb);
1679 let yes_id = tree
1680 .find_by_label(&StandardButton::Yes.default_label().resolve_now())
1681 .unwrap();
1682 assert_eq!(tree.focused(), Some(yes_id));
1683 }
1684
1685 #[test]
1694 fn enter_fires_the_focused_button_rather_than_the_default() {
1695 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1696 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1697 let captured_for_handler = captured.clone();
1698 let mb = MessageBox::question(lit!("t"))
1699 .text(lit!("x"))
1700 .buttons(MessageBoxButtons::OkCancel)
1701 .on_result(move |r, _ctx| {
1702 *captured_for_handler.borrow_mut() = Some(r);
1703 });
1704 let _content = present_and_lay_out(&mut tree, mb);
1705 let cancel_id = tree
1706 .find_by_label(&StandardButton::Cancel.default_label().resolve_now())
1707 .unwrap();
1708 tree.focus(cancel_id);
1709 tree.press_key(Key::Enter, Modifiers::NONE);
1710 let result = captured.borrow().expect("result must be captured");
1711 assert_eq!(
1712 result.button,
1713 StandardButton::Cancel,
1714 "Enter must answer for the focused button, not for the default"
1715 );
1716 assert_eq!(result.dismissal, MessageBoxDismissal::Button);
1717 }
1718
1719 #[test]
1725 fn enter_still_fires_the_default_when_the_focus_is_not_a_button() {
1726 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1727 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1728 let captured_for_handler = captured.clone();
1729 let mb = MessageBox::question(lit!("t"))
1730 .text(lit!("x"))
1731 .buttons(MessageBoxButtons::OkCancel)
1732 .show_again_checkbox(lit!("Ne plus demander"))
1733 .on_result(move |r, _ctx| {
1734 *captured_for_handler.borrow_mut() = Some(r);
1735 });
1736 let _content = present_and_lay_out(&mut tree, mb);
1737 let checkbox_id = tree.find_by_label("Ne plus demander").unwrap();
1738 tree.focus(checkbox_id);
1739 tree.press_key(Key::Enter, Modifiers::NONE);
1740 let result = captured.borrow().expect("result must be captured");
1741 assert_eq!(result.button, StandardButton::Ok);
1742 assert_eq!(result.dismissal, MessageBoxDismissal::Button);
1743 }
1744
1745 #[test]
1746 fn escape_fires_escape_button_and_marks_dismissed() {
1747 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1748 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1749 let captured_for_handler = captured.clone();
1750 let mb = MessageBox::question(lit!("t"))
1751 .text(lit!("x"))
1752 .buttons(MessageBoxButtons::YesNoCancel)
1753 .on_result(move |r, _ctx| {
1754 *captured_for_handler.borrow_mut() = Some(r);
1755 });
1756 let _content = present_and_lay_out(&mut tree, mb);
1757 tree.press_key(Key::Escape, Modifiers::NONE);
1758 let result = captured.borrow().expect("result must be captured");
1759 assert_eq!(result.button, StandardButton::Cancel);
1760 assert_eq!(result.dismissal, MessageBoxDismissal::Escape);
1761 }
1762
1763 #[test]
1764 fn checkbox_state_reported_in_result() {
1765 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1766 let shared_state = Signal::new(false);
1767 let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
1768 let captured_for_handler = captured.clone();
1769 let mb = MessageBox::information(lit!("t"))
1770 .text(lit!("x"))
1771 .buttons(MessageBoxButtons::Ok)
1772 .show_again_checkbox_state(shared_state.clone())
1773 .show_again_checkbox(lit!("Don't show again"))
1774 .on_result(move |r, _ctx| {
1775 *captured_for_handler.borrow_mut() = Some(r);
1776 });
1777 let _content = present_and_lay_out(&mut tree, mb);
1778 shared_state.set(true);
1779 let ok_id = tree
1780 .find_by_label(&StandardButton::Ok.default_label().resolve_now())
1781 .unwrap();
1782 tree.dispatch_event(WidgetEvent::AccessAction {
1783 action: teksilo_core::accesskit::Action::Click,
1784 target: Some(ok_id),
1785 target_node: teksilo_core::accessibility::root_node_id(),
1786 data: None,
1787 });
1788 assert!(captured.borrow().unwrap().checkbox_checked);
1789 }
1790
1791 #[test]
1792 fn the_presenting_container_is_no_second_dialog() {
1793 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1802 let mb = MessageBox::information(lit!("Title propagation test"))
1803 .text(lit!("Body"))
1804 .buttons(MessageBoxButtons::Ok);
1805 let content = present_and_lay_out(&mut tree, mb);
1806 let container = tree.accessibility_node(content);
1807 assert_eq!(
1808 container.role(),
1809 teksilo_core::accesskit::Role::GenericContainer
1810 );
1811 assert_eq!(container.name(), None);
1812 let panel = tree.children(content).first().copied().unwrap();
1813 let mb_id = tree.children(panel).first().copied().unwrap();
1814 let info = tree.accessibility_node(mb_id);
1815 assert_eq!(info.role(), teksilo_core::accesskit::Role::AlertDialog);
1816 assert_eq!(info.name(), Some("Title propagation test"));
1817 }
1818
1819 #[test]
1820 fn standard_button_roles_classify_correctly() {
1821 assert_eq!(StandardButton::Ok.role(), ButtonRole::Accept);
1822 assert_eq!(StandardButton::Yes.role(), ButtonRole::Accept);
1823 assert_eq!(StandardButton::Save.role(), ButtonRole::Accept);
1824 assert_eq!(StandardButton::Cancel.role(), ButtonRole::Reject);
1825 assert_eq!(StandardButton::No.role(), ButtonRole::Reject);
1826 assert_eq!(StandardButton::Abort.role(), ButtonRole::Reject);
1827 assert_eq!(StandardButton::Discard.role(), ButtonRole::Destructive);
1828 assert_eq!(StandardButton::Help.role(), ButtonRole::Action);
1829 assert_eq!(StandardButton::Ignore.role(), ButtonRole::Action);
1830 }
1831
1832 #[test]
1845 fn a_long_details_pane_expands_and_keeps_the_dialog_intact() {
1846 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1847 let long: String = (1..=100)
1848 .map(|i| format!("line {i} of a very long detail dump\n"))
1849 .collect();
1850 let mb = MessageBox::critical(lit!("Could not open file"))
1851 .text(lit!("It went wrong."))
1852 .detailed_text(lit!(long))
1853 .buttons(MessageBoxButtons::Ok);
1854 let _content = present_and_lay_out(&mut tree, mb);
1855
1856 let toggle = tree
1857 .find_by_label(&teksilo_i18n::tr_widget!(messagebox_show_details()).resolve_now())
1858 .expect("the Show details toggle");
1859 tree.dispatch_event(WidgetEvent::AccessAction {
1860 action: teksilo_core::accesskit::Action::Click,
1861 target: Some(toggle),
1862 target_node: teksilo_core::accessibility::root_node_id(),
1863 data: None,
1864 });
1865 tree.layout(SizeProposal::exact(800.0, 600.0));
1866
1867 assert!(tree.find_by_label("Could not open file").is_some());
1869 assert!(
1870 tree.find_by_label(&StandardButton::Ok.default_label().resolve_now())
1871 .is_some(),
1872 "the button row must survive an expanded details pane"
1873 );
1874 }
1875
1876 #[test]
1877 fn escape_resolution_prefers_explicit_escape_button() {
1878 let state = State::new(Signal::new(false));
1879 *state.buttons.borrow_mut() = vec![StandardButton::Save, StandardButton::Discard];
1880 state.escape_button.set(Some(StandardButton::Discard));
1881 assert_eq!(state.resolve_escape_button(), Some(StandardButton::Discard));
1882 }
1883
1884 #[test]
1885 fn escape_resolution_falls_back_to_first_reject() {
1886 let state = State::new(Signal::new(false));
1887 *state.buttons.borrow_mut() = vec![
1888 StandardButton::Retry,
1889 StandardButton::Ignore,
1890 StandardButton::Abort,
1891 ];
1892 state.escape_button.set(None);
1893 assert_eq!(state.resolve_escape_button(), Some(StandardButton::Abort));
1894 }
1895
1896 #[test]
1897 fn escape_resolution_falls_back_to_last_when_no_reject() {
1898 let state = State::new(Signal::new(false));
1899 *state.buttons.borrow_mut() = vec![StandardButton::Ok, StandardButton::Help];
1900 state.escape_button.set(None);
1901 assert_eq!(state.resolve_escape_button(), Some(StandardButton::Help));
1902 }
1903}