use std::cell::{Cell, RefCell};
use std::rc::Rc;
use teksilo_canvas::{Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::action::Action;
use teksilo_core::build_context::BuildContext;
use teksilo_core::event::{Key, Modifiers};
use teksilo_core::modal::{ModalCloseBehavior, ModalPresentation, ModalRequest};
use teksilo_core::shortcut::{KeyStroke, Shortcut};
use teksilo_core::signal::Signal;
use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
use teksilo_core::widget_id::WidgetId;
use teksilo_i18n::LocalizedString;
use teksilo_tokens::VAlignment;
use crate::accordion::Accordion;
use crate::button::{Button, ButtonVariant};
use crate::checkbox::Checkbox;
use crate::dialog::ModalContainer;
use crate::primitives::{Expand, HStack, Spacer, TextWidget, VStack};
use crate::scroll_area::ScrollArea;
use crate::severity_badge::{SeverityBadge, SeverityIconKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MessageBoxSeverity {
#[default]
None,
Information,
Question,
Warning,
Critical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ButtonRole {
Accept,
Reject,
Destructive,
Action,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StandardButton {
Ok,
Cancel,
Close,
Yes,
No,
YesToAll,
NoToAll,
Save,
SaveAll,
Discard,
Apply,
Reset,
RestoreDefaults,
Abort,
Retry,
Ignore,
Open,
Help,
}
impl StandardButton {
pub fn role(self) -> ButtonRole {
match self {
Self::Ok
| Self::Yes
| Self::YesToAll
| Self::Save
| Self::SaveAll
| Self::Apply
| Self::Retry
| Self::Open => ButtonRole::Accept,
Self::Cancel | Self::Close | Self::No | Self::NoToAll | Self::Abort => {
ButtonRole::Reject
}
Self::Discard => ButtonRole::Destructive,
Self::Reset | Self::RestoreDefaults | Self::Ignore | Self::Help => ButtonRole::Action,
}
}
pub fn intent_name(self) -> &'static str {
match self {
Self::Ok => "messagebox.btn.ok",
Self::Cancel => "messagebox.btn.cancel",
Self::Close => "messagebox.btn.close",
Self::Yes => "messagebox.btn.yes",
Self::No => "messagebox.btn.no",
Self::YesToAll => "messagebox.btn.yes_to_all",
Self::NoToAll => "messagebox.btn.no_to_all",
Self::Save => "messagebox.btn.save",
Self::SaveAll => "messagebox.btn.save_all",
Self::Discard => "messagebox.btn.discard",
Self::Apply => "messagebox.btn.apply",
Self::Reset => "messagebox.btn.reset",
Self::RestoreDefaults => "messagebox.btn.restore_defaults",
Self::Abort => "messagebox.btn.abort",
Self::Retry => "messagebox.btn.retry",
Self::Ignore => "messagebox.btn.ignore",
Self::Open => "messagebox.btn.open",
Self::Help => "messagebox.btn.help",
}
}
pub fn default_label(self) -> LocalizedString {
match self {
Self::Ok => teksilo_i18n::tr_widget!(messagebox_btn_ok()),
Self::Cancel => teksilo_i18n::tr_widget!(messagebox_btn_cancel()),
Self::Close => teksilo_i18n::tr_widget!(messagebox_btn_close()),
Self::Yes => teksilo_i18n::tr_widget!(messagebox_btn_yes()),
Self::No => teksilo_i18n::tr_widget!(messagebox_btn_no()),
Self::YesToAll => teksilo_i18n::tr_widget!(messagebox_btn_yes_to_all()),
Self::NoToAll => teksilo_i18n::tr_widget!(messagebox_btn_no_to_all()),
Self::Save => teksilo_i18n::tr_widget!(messagebox_btn_save()),
Self::SaveAll => teksilo_i18n::tr_widget!(messagebox_btn_save_all()),
Self::Discard => teksilo_i18n::tr_widget!(messagebox_btn_discard()),
Self::Apply => teksilo_i18n::tr_widget!(messagebox_btn_apply()),
Self::Reset => teksilo_i18n::tr_widget!(messagebox_btn_reset()),
Self::RestoreDefaults => teksilo_i18n::tr_widget!(messagebox_btn_restore_defaults()),
Self::Abort => teksilo_i18n::tr_widget!(messagebox_btn_abort()),
Self::Retry => teksilo_i18n::tr_widget!(messagebox_btn_retry()),
Self::Ignore => teksilo_i18n::tr_widget!(messagebox_btn_ignore()),
Self::Open => teksilo_i18n::tr_widget!(messagebox_btn_open()),
Self::Help => teksilo_i18n::tr_widget!(messagebox_btn_help()),
}
}
}
#[derive(Debug, Clone)]
pub struct MessageBoxButton {
pub kind: StandardButton,
pub label_override: Option<LocalizedString>,
}
impl MessageBoxButton {
pub fn standard(kind: StandardButton) -> Self {
Self {
kind,
label_override: None,
}
}
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
self.label_override = Some(label.into());
self
}
fn resolved_label(&self) -> LocalizedString {
self.label_override
.clone()
.unwrap_or_else(|| self.kind.default_label())
}
}
impl From<StandardButton> for MessageBoxButton {
fn from(kind: StandardButton) -> Self {
Self::standard(kind)
}
}
#[derive(Debug, Clone)]
pub enum MessageBoxButtons {
Ok,
OkCancel,
YesNo,
YesNoCancel,
SaveDiscardCancel,
RetryIgnoreAbort,
Custom(Vec<MessageBoxButton>),
}
impl MessageBoxButtons {
fn into_buttons(self) -> Vec<MessageBoxButton> {
match self {
Self::Ok => vec![StandardButton::Ok.into()],
Self::OkCancel => vec![StandardButton::Cancel.into(), StandardButton::Ok.into()],
Self::YesNo => vec![StandardButton::No.into(), StandardButton::Yes.into()],
Self::YesNoCancel => vec![
StandardButton::Cancel.into(),
StandardButton::No.into(),
StandardButton::Yes.into(),
],
Self::SaveDiscardCancel => vec![
StandardButton::Discard.into(),
StandardButton::Cancel.into(),
StandardButton::Save.into(),
],
Self::RetryIgnoreAbort => vec![
StandardButton::Abort.into(),
StandardButton::Ignore.into(),
StandardButton::Retry.into(),
],
Self::Custom(items) => items,
}
}
fn preset_default(&self) -> Option<StandardButton> {
match self {
Self::Ok => Some(StandardButton::Ok),
Self::OkCancel => Some(StandardButton::Ok),
Self::YesNo => Some(StandardButton::Yes),
Self::YesNoCancel => Some(StandardButton::Yes),
Self::SaveDiscardCancel => Some(StandardButton::Save),
Self::RetryIgnoreAbort => Some(StandardButton::Retry),
Self::Custom(_) => None,
}
}
fn preset_escape(&self) -> Option<StandardButton> {
match self {
Self::Ok => Some(StandardButton::Ok),
Self::OkCancel => Some(StandardButton::Cancel),
Self::YesNo => Some(StandardButton::No),
Self::YesNoCancel => Some(StandardButton::Cancel),
Self::SaveDiscardCancel => Some(StandardButton::Cancel),
Self::RetryIgnoreAbort => Some(StandardButton::Abort),
Self::Custom(_) => None,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct MessageBoxResult {
pub button: StandardButton,
pub checkbox_checked: bool,
pub dismissed_by_escape: bool,
}
const SEVERITY_ICON_SIZE: f32 = 48.0;
const DETAILS_MAX_HEIGHT: f32 = 220.0;
const DEFAULT_INTENT_NAME: &str = "messagebox.accept_default";
const ESCAPE_INTENT_NAME: &str = "messagebox.escape";
fn severity_icon_kind(severity: MessageBoxSeverity) -> Option<SeverityIconKind> {
match severity {
MessageBoxSeverity::None => None,
MessageBoxSeverity::Information => Some(SeverityIconKind::Info),
MessageBoxSeverity::Question => Some(SeverityIconKind::Question),
MessageBoxSeverity::Warning => Some(SeverityIconKind::Warning),
MessageBoxSeverity::Critical => Some(SeverityIconKind::Error),
}
}
struct State {
on_result: RefCell<Option<Box<dyn Fn(MessageBoxResult, &mut EventContext)>>>,
checkbox: Signal<bool>,
escape_button: Cell<Option<StandardButton>>,
default_button: Cell<Option<StandardButton>>,
buttons: RefCell<Vec<StandardButton>>,
fired: Cell<bool>,
}
impl State {
fn new(checkbox: Signal<bool>) -> Rc<Self> {
Rc::new(Self {
on_result: RefCell::new(None),
checkbox,
escape_button: Cell::new(None),
default_button: Cell::new(None),
buttons: RefCell::new(Vec::new()),
fired: Cell::new(false),
})
}
fn fire(&self, button: StandardButton, by_escape: bool, ctx: &mut EventContext) {
if self.fired.replace(true) {
return;
}
let result = MessageBoxResult {
button,
checkbox_checked: self.checkbox.get(),
dismissed_by_escape: by_escape,
};
if let Some(handler) = self.on_result.borrow().as_ref() {
handler(result, ctx);
}
ctx.dismiss_modal();
}
fn resolve_escape_button(&self) -> Option<StandardButton> {
if let Some(btn) = self.escape_button.get() {
return Some(btn);
}
let buttons = self.buttons.borrow();
if let Some(btn) = buttons.iter().find(|b| b.role() == ButtonRole::Reject) {
return Some(*btn);
}
if buttons.contains(&StandardButton::Cancel) {
return Some(StandardButton::Cancel);
}
buttons.last().copied()
}
}
pub struct MessageBox {
severity: MessageBoxSeverity,
title: LocalizedString,
text: Option<LocalizedString>,
informative_text: Option<LocalizedString>,
detailed_text: Option<LocalizedString>,
buttons_config: Option<MessageBoxButtons>,
extra_buttons: Vec<MessageBoxButton>,
default_button: Option<StandardButton>,
escape_button: Option<StandardButton>,
show_again_label: Option<LocalizedString>,
show_again_state: Option<Signal<bool>>,
on_result: Option<Box<dyn Fn(MessageBoxResult, &mut EventContext)>>,
default_button_id: Cell<Option<WidgetId>>,
root_child_id: Option<WidgetId>,
state: Option<Rc<State>>,
}
impl std::fmt::Debug for MessageBox {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MessageBox")
.field("severity", &self.severity)
.field("title", &self.title)
.field("text", &self.text)
.field("informative_text", &self.informative_text)
.field("detailed_text", &self.detailed_text)
.field("default_button", &self.default_button)
.field("escape_button", &self.escape_button)
.finish()
}
}
impl MessageBox {
fn new_with_severity(severity: MessageBoxSeverity, title: impl Into<LocalizedString>) -> Self {
let title = title.into();
Self {
severity,
title,
text: None,
informative_text: None,
detailed_text: None,
buttons_config: None,
extra_buttons: Vec::new(),
default_button: None,
escape_button: None,
show_again_label: None,
show_again_state: None,
on_result: None,
default_button_id: Cell::new(None),
root_child_id: None,
state: None,
}
}
pub fn information(title: impl Into<LocalizedString>) -> Self {
Self::new_with_severity(MessageBoxSeverity::Information, title)
}
pub fn warning(title: impl Into<LocalizedString>) -> Self {
Self::new_with_severity(MessageBoxSeverity::Warning, title)
}
pub fn critical(title: impl Into<LocalizedString>) -> Self {
Self::new_with_severity(MessageBoxSeverity::Critical, title)
}
pub fn question(title: impl Into<LocalizedString>) -> Self {
Self::new_with_severity(MessageBoxSeverity::Question, title)
}
pub fn plain(title: impl Into<LocalizedString>) -> Self {
Self::new_with_severity(MessageBoxSeverity::None, title)
}
pub fn text(mut self, text: impl Into<LocalizedString>) -> Self {
self.text = Some(text.into());
self
}
pub fn informative_text(mut self, text: impl Into<LocalizedString>) -> Self {
self.informative_text = Some(text.into());
self
}
pub fn detailed_text(mut self, text: impl Into<LocalizedString>) -> Self {
self.detailed_text = Some(text.into());
self
}
pub fn buttons(mut self, preset: MessageBoxButtons) -> Self {
if self.default_button.is_none() {
self.default_button = preset.preset_default();
}
if self.escape_button.is_none() {
self.escape_button = preset.preset_escape();
}
self.buttons_config = Some(preset);
self
}
pub fn add_button(mut self, button: impl Into<MessageBoxButton>) -> Self {
self.extra_buttons.push(button.into());
self
}
pub fn default_button(mut self, which: StandardButton) -> Self {
self.default_button = Some(which);
self
}
pub fn escape_button(mut self, which: StandardButton) -> Self {
self.escape_button = Some(which);
self
}
pub fn show_again_checkbox(mut self, label: impl Into<LocalizedString>) -> Self {
self.show_again_label = Some(label.into());
self
}
pub fn show_again_checkbox_state(mut self, signal: Signal<bool>) -> Self {
self.show_again_state = Some(signal);
self
}
pub fn on_result(mut self, f: impl Fn(MessageBoxResult, &mut EventContext) + 'static) -> Self {
self.on_result = Some(Box::new(f));
self
}
pub fn present(self, ctx: &mut EventContext) {
let title = self.title.clone();
let close_behavior = if self.severity == MessageBoxSeverity::Critical {
ModalCloseBehavior::EscapeKey
} else {
ModalCloseBehavior::EscapeOrClickOutside
};
let dialog_title = self.title.clone();
let mut inner = Some(self);
ctx.present_modal(
ModalRequest::deferred(move |tree| {
let mb = inner
.take()
.expect("MessageBox present closure called twice");
tree.add(ModalContainer::new(mb).title(dialog_title.clone()))
})
.presentation(ModalPresentation::Auto)
.close_behavior(close_behavior)
.title(title)
.size(460, 140),
);
}
fn resolve_buttons(&mut self) -> Vec<MessageBoxButton> {
let mut resolved = self
.buttons_config
.clone()
.map(|b| b.into_buttons())
.unwrap_or_default();
resolved.extend(self.extra_buttons.iter().cloned());
if resolved.is_empty() {
resolved.push(StandardButton::Ok.into());
if self.default_button.is_none() {
self.default_button = Some(StandardButton::Ok);
}
if self.escape_button.is_none() {
self.escape_button = Some(StandardButton::Ok);
}
}
resolved
}
}
impl Widget for MessageBox {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let theme = ctx.theme().clone();
let checkbox_signal = self
.show_again_state
.clone()
.unwrap_or_else(|| ctx.signal(false));
let state = State::new(checkbox_signal.clone());
*state.on_result.borrow_mut() = self.on_result.take();
let buttons = self.resolve_buttons();
*state.buttons.borrow_mut() = buttons.iter().map(|b| b.kind).collect();
state.default_button.set(self.default_button);
state.escape_button.set(self.escape_button);
let mut header_text_stack = VStack::new().spacing(6.0);
header_text_stack = header_text_stack.child(
TextWidget::new(self.title.clone())
.style(theme.typography.body_bold.clone())
.color(theme.colors.text_primary),
);
if let Some(text) = self.text.clone() {
header_text_stack = header_text_stack.child(
TextWidget::new(text)
.style(theme.typography.body.clone())
.color(theme.colors.text_primary),
);
}
if let Some(info) = self.informative_text.clone() {
header_text_stack = header_text_stack.child(
TextWidget::new(info)
.style(theme.typography.body.clone())
.color(theme.colors.text_secondary),
);
}
let header: Box<dyn Widget> = if let Some(kind) = severity_icon_kind(self.severity) {
Box::new(
HStack::new()
.spacing(16.0)
.alignment(VAlignment::Top)
.child(SeverityBadge::new(kind, SEVERITY_ICON_SIZE))
.child(Expand::horizontal().child(header_text_stack)),
)
} else {
Box::new(header_text_stack)
};
let detailed_child: Option<Box<dyn Widget>> = self.detailed_text.clone().map(|text| {
let expanded = ctx.signal(false);
let label: LocalizedString = teksilo_i18n::tr_widget!(messagebox_show_details());
let body = TextWidget::new(text)
.style(theme.typography.small.clone())
.color(theme.colors.text_secondary);
let scroller = ScrollArea::new()
.child(body)
.preferred_height(DETAILS_MAX_HEIGHT);
let accordion: Box<dyn Widget> =
Box::new(Accordion::new(label, expanded).content(scroller));
accordion
});
let checkbox_child: Option<Box<dyn Widget>> = self.show_again_label.clone().map(|label| {
let cb: Box<dyn Widget> = Box::new(Checkbox::new(checkbox_signal.clone()).label(label));
cb
});
let mut footer = HStack::new().spacing(8.0).child(Spacer::new());
for button_cfg in &buttons {
let kind = button_cfg.kind;
let label = button_cfg.resolved_label();
let variant = if Some(kind) == self.default_button {
ButtonVariant::Filled
} else {
ButtonVariant::Plain
};
let state_for_btn = state.clone();
let btn_id = ctx.add(
Button::new(label)
.variant(variant)
.on_activate_fn(move |ctx| {
state_for_btn.fire(kind, false, ctx);
}),
);
if Some(kind) == self.default_button {
self.default_button_id.set(Some(btn_id));
}
footer = footer.add_child(btn_id);
}
let mut stack = VStack::new().spacing(16.0);
stack = stack.add_child(ctx.add_boxed(header));
if let Some(det) = detailed_child {
stack = stack.add_child(ctx.add_boxed(det));
}
if let Some(cb) = checkbox_child {
stack = stack.add_child(ctx.add_boxed(cb));
}
stack = stack.add_child(ctx.add(Spacer::new()));
let footer_id = ctx.add(footer);
stack = stack.add_child(footer_id);
let root = ctx.add(stack);
self.root_child_id = Some(root);
{
let state_enter = state.clone();
ctx.register_action(
Action::new(DEFAULT_INTENT_NAME).on_invoke(move |_intent, ctx| {
if let Some(kind) = state_enter.default_button.get() {
state_enter.fire(kind, false, ctx);
}
}),
);
ctx.register_shortcut(
Shortcut::new(DEFAULT_INTENT_NAME)
.primary(KeyStroke::new(Key::Enter, Modifiers::NONE))
.build(),
);
}
{
let state_escape = state.clone();
ctx.register_action(
Action::new(ESCAPE_INTENT_NAME).on_invoke(move |_intent, ctx| {
if let Some(kind) = state_escape.resolve_escape_button() {
state_escape.fire(kind, true, ctx);
} else {
ctx.dismiss_modal();
}
}),
);
ctx.register_shortcut(
Shortcut::new(ESCAPE_INTENT_NAME)
.primary(KeyStroke::new(Key::Escape, Modifiers::NONE))
.build(),
);
}
self.state = Some(state);
vec![root]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let child = self
.root_child_id
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| proposal.resolve(0.0, 0.0));
Size::new(child.width.max(460.0), child.height.max(140.0)).into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(teksilo_core::accesskit::Role::AlertDialog);
builder.set_name(self.title.clone());
if let Some(description) = self.accessible_description() {
builder.set_description(description);
}
builder.set_modal();
builder.set_live(teksilo_core::accesskit::Live::Assertive);
builder.add_action(teksilo_core::accesskit::Action::Focus);
}
fn accessible_title_hint(&self) -> Option<String> {
Some(self.title.resolve_now())
}
fn initial_focus_hint(&self) -> Option<WidgetId> {
self.default_button_id.get()
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.into_iter().collect()
}
}
impl MessageBox {
fn accessible_description(&self) -> Option<String> {
match (
self.text.as_ref().map(|t| t.resolve_now()),
self.informative_text.as_ref().map(|i| i.resolve_now()),
) {
(None, None) => None,
(Some(t), None) => Some(t),
(None, Some(i)) => Some(i),
(Some(t), Some(i)) => Some(format!("{t}\n{i}")),
}
}
}
pub trait EventContextMessageBoxExt {
fn present_message_box(&mut self, mb: MessageBox);
}
impl EventContextMessageBoxExt for EventContext<'_> {
fn present_message_box(&mut self, mb: MessageBox) {
mb.present(self);
}
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_core::ModalContent;
use teksilo_core::event::WidgetEvent;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
fn present_and_lay_out(tree: &mut WidgetTree, mb: MessageBox) -> WidgetId {
use crate::button::Button as Btn;
let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(mb)));
let mb_for_closure = mb_cell.clone();
let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
if let Some(mb) = mb_for_closure.borrow_mut().take() {
mb.present(ctx);
}
}));
tree.layout(SizeProposal::exact(800.0, 600.0));
tree.dispatch_event(WidgetEvent::AccessAction {
action: teksilo_core::accesskit::Action::Click,
target: Some(trigger),
target_node: teksilo_core::accessibility::root_node_id(),
data: None,
});
let request = tree.drain_pending_modal_requests().pop().unwrap().request;
let content_id = match request.content {
ModalContent::Deferred(builder) => builder(tree),
ModalContent::ExistingWidget(_) => panic!("MessageBox must use deferred content"),
};
tree.layout(SizeProposal::exact(800.0, 600.0));
let focus_target = request
.focus_target
.filter(|id| tree.is_active(*id) && tree.is_descendant_of(*id, content_id))
.or_else(|| tree.widget_initial_focus_hint(content_id))
.or_else(|| tree.first_focusable_descendant(content_id));
if let Some(id) = focus_target {
tree.focus(id);
}
content_id
}
#[test]
fn present_queues_modal_request() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let mb = MessageBox::information(lit!("t"))
.text(lit!("x"))
.buttons(MessageBoxButtons::Ok);
let _content = present_and_lay_out(&mut tree, mb);
assert!(tree.find_by_label("t").is_some());
}
#[test]
fn critical_uses_escape_only_close_behavior() {
use crate::button::Button as Btn;
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let mb_cell: Rc<RefCell<Option<MessageBox>>> = Rc::new(RefCell::new(Some(
MessageBox::critical(lit!("Fatal"))
.text(lit!("Boom"))
.buttons(MessageBoxButtons::Ok),
)));
let mb_for_closure = mb_cell.clone();
let trigger = tree.add(Btn::new(lit!("Open")).on_activate_fn(move |ctx| {
if let Some(mb) = mb_for_closure.borrow_mut().take() {
mb.present(ctx);
}
}));
tree.layout(SizeProposal::exact(800.0, 600.0));
tree.dispatch_event(WidgetEvent::AccessAction {
action: teksilo_core::accesskit::Action::Click,
target: Some(trigger),
target_node: teksilo_core::accessibility::root_node_id(),
data: None,
});
let request = tree.drain_pending_modal_requests().pop().unwrap().request;
assert_eq!(request.close_behavior, ModalCloseBehavior::EscapeKey);
}
#[test]
fn alert_dialog_role_exposed() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let mb = MessageBox::warning(lit!("Title"))
.text(lit!("Body"))
.buttons(MessageBoxButtons::Ok);
let content = present_and_lay_out(&mut tree, mb);
let panel = tree.children(content).first().copied().unwrap();
let mb_id = tree.children(panel).first().copied().unwrap();
let info = tree.accessibility_node(mb_id);
assert_eq!(info.role(), teksilo_core::accesskit::Role::AlertDialog);
assert_eq!(info.name(), Some("Title"));
}
#[test]
fn ok_button_fires_result_with_correct_kind() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
let captured_for_handler = captured.clone();
let mb = MessageBox::information(lit!("t"))
.text(lit!("x"))
.buttons(MessageBoxButtons::Ok)
.on_result(move |r, _ctx| {
*captured_for_handler.borrow_mut() = Some(r);
});
let _content = present_and_lay_out(&mut tree, mb);
let ok_id = tree
.find_by_label(&StandardButton::Ok.default_label().resolve_now())
.unwrap();
tree.dispatch_event(WidgetEvent::AccessAction {
action: teksilo_core::accesskit::Action::Click,
target: Some(ok_id),
target_node: teksilo_core::accessibility::root_node_id(),
data: None,
});
let result = captured.borrow().expect("result must be captured");
assert_eq!(result.button, StandardButton::Ok);
assert!(!result.checkbox_checked);
assert!(!result.dismissed_by_escape);
}
#[test]
fn default_button_is_focused_on_open() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let mb = MessageBox::question(lit!("t"))
.text(lit!("x"))
.buttons(MessageBoxButtons::YesNoCancel)
.default_button(StandardButton::No);
let _content = present_and_lay_out(&mut tree, mb);
let no_id = tree
.find_by_label(&StandardButton::No.default_label().resolve_now())
.unwrap();
assert_eq!(tree.focused(), Some(no_id));
}
#[test]
fn enter_fires_default_button_from_any_focus() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
let captured_for_handler = captured.clone();
let mb = MessageBox::question(lit!("t"))
.text(lit!("x"))
.buttons(MessageBoxButtons::OkCancel)
.on_result(move |r, _ctx| {
*captured_for_handler.borrow_mut() = Some(r);
});
let _content = present_and_lay_out(&mut tree, mb);
let cancel_id = tree
.find_by_label(&StandardButton::Cancel.default_label().resolve_now())
.unwrap();
tree.focus(cancel_id);
tree.press_key(Key::Enter, Modifiers::NONE);
let result = captured.borrow().expect("result must be captured");
assert_eq!(result.button, StandardButton::Ok);
assert!(!result.dismissed_by_escape);
}
#[test]
fn escape_fires_escape_button_and_marks_dismissed() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
let captured_for_handler = captured.clone();
let mb = MessageBox::question(lit!("t"))
.text(lit!("x"))
.buttons(MessageBoxButtons::YesNoCancel)
.on_result(move |r, _ctx| {
*captured_for_handler.borrow_mut() = Some(r);
});
let _content = present_and_lay_out(&mut tree, mb);
tree.press_key(Key::Escape, Modifiers::NONE);
let result = captured.borrow().expect("result must be captured");
assert_eq!(result.button, StandardButton::Cancel);
assert!(result.dismissed_by_escape);
}
#[test]
fn checkbox_state_reported_in_result() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let shared_state = Signal::new(false);
let captured: Rc<RefCell<Option<MessageBoxResult>>> = Rc::new(RefCell::new(None));
let captured_for_handler = captured.clone();
let mb = MessageBox::information(lit!("t"))
.text(lit!("x"))
.buttons(MessageBoxButtons::Ok)
.show_again_checkbox_state(shared_state.clone())
.show_again_checkbox(lit!("Don't show again"))
.on_result(move |r, _ctx| {
*captured_for_handler.borrow_mut() = Some(r);
});
let _content = present_and_lay_out(&mut tree, mb);
shared_state.set(true);
let ok_id = tree
.find_by_label(&StandardButton::Ok.default_label().resolve_now())
.unwrap();
tree.dispatch_event(WidgetEvent::AccessAction {
action: teksilo_core::accesskit::Action::Click,
target: Some(ok_id),
target_node: teksilo_core::accessibility::root_node_id(),
data: None,
});
assert!(captured.borrow().unwrap().checkbox_checked);
}
#[test]
fn accessible_title_hint_propagates_to_container() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let mb = MessageBox::information(lit!("Title propagation test"))
.text(lit!("Body"))
.buttons(MessageBoxButtons::Ok);
let content = present_and_lay_out(&mut tree, mb);
let info = tree.accessibility_node(content);
assert_eq!(info.role(), teksilo_core::accesskit::Role::Dialog);
assert_eq!(info.name(), Some("Title propagation test"));
}
#[test]
fn standard_button_roles_classify_correctly() {
assert_eq!(StandardButton::Ok.role(), ButtonRole::Accept);
assert_eq!(StandardButton::Yes.role(), ButtonRole::Accept);
assert_eq!(StandardButton::Save.role(), ButtonRole::Accept);
assert_eq!(StandardButton::Cancel.role(), ButtonRole::Reject);
assert_eq!(StandardButton::No.role(), ButtonRole::Reject);
assert_eq!(StandardButton::Abort.role(), ButtonRole::Reject);
assert_eq!(StandardButton::Discard.role(), ButtonRole::Destructive);
assert_eq!(StandardButton::Help.role(), ButtonRole::Action);
assert_eq!(StandardButton::Ignore.role(), ButtonRole::Action);
}
#[test]
fn a_long_details_pane_expands_and_keeps_the_dialog_intact() {
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let long: String = (1..=100)
.map(|i| format!("line {i} of a very long detail dump\n"))
.collect();
let mb = MessageBox::critical(lit!("Could not open file"))
.text(lit!("It went wrong."))
.detailed_text(lit!(long))
.buttons(MessageBoxButtons::Ok);
let _content = present_and_lay_out(&mut tree, mb);
let toggle = tree
.find_by_label(&teksilo_i18n::tr_widget!(messagebox_show_details()).resolve_now())
.expect("the Show details toggle");
tree.dispatch_event(WidgetEvent::AccessAction {
action: teksilo_core::accesskit::Action::Click,
target: Some(toggle),
target_node: teksilo_core::accessibility::root_node_id(),
data: None,
});
tree.layout(SizeProposal::exact(800.0, 600.0));
assert!(tree.find_by_label("Could not open file").is_some());
assert!(
tree.find_by_label(&StandardButton::Ok.default_label().resolve_now())
.is_some(),
"the button row must survive an expanded details pane"
);
}
#[test]
fn escape_resolution_prefers_explicit_escape_button() {
let state = State::new(Signal::new(false));
*state.buttons.borrow_mut() = vec![StandardButton::Save, StandardButton::Discard];
state.escape_button.set(Some(StandardButton::Discard));
assert_eq!(state.resolve_escape_button(), Some(StandardButton::Discard));
}
#[test]
fn escape_resolution_falls_back_to_first_reject() {
let state = State::new(Signal::new(false));
*state.buttons.borrow_mut() = vec![
StandardButton::Retry,
StandardButton::Ignore,
StandardButton::Abort,
];
state.escape_button.set(None);
assert_eq!(state.resolve_escape_button(), Some(StandardButton::Abort));
}
#[test]
fn escape_resolution_falls_back_to_last_when_no_reject() {
let state = State::new(Signal::new(false));
*state.buttons.borrow_mut() = vec![StandardButton::Ok, StandardButton::Help];
state.escape_button.set(None);
assert_eq!(state.resolve_escape_button(), Some(StandardButton::Help));
}
}