use crate::core::{Color, Font, HorizontalAlignment, ObjectId, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::impl_widget_property_hooks;
use crate::layout::hints::{ChildInfo, Hints, LayoutParams};
use crate::layout::{FlexLayout, JustifyContent, Layout};
use crate::property_names_of;
use crate::render::RenderContext;
use crate::signal::{GenericSignal, Signal1};
use crate::style::{EdgeOffsets, MotionSlot, PropertyDriver, SemanticColor};
use crate::tr;
use crate::widget::capability::coercion::{
expect_bool, expect_message_box_icon, expect_string, message_box_icon_to_str,
};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::{dimensions, ControlMetrics};
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageBoxIcon {
NoIcon,
Information,
Question,
Warning,
Critical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StandardButton {
Ok,
Cancel,
Yes,
No,
YesAll,
NoAll,
Save,
Discard,
Apply,
Close,
Abort,
Retry,
Ignore,
Help,
}
impl StandardButton {
pub fn label(&self) -> &'static str {
match self {
StandardButton::Ok => "OK",
StandardButton::Cancel => "Cancel",
StandardButton::Yes => "Yes",
StandardButton::No => "No",
StandardButton::YesAll => "Yes to All",
StandardButton::NoAll => "No to All",
StandardButton::Save => "Save",
StandardButton::Discard => "Discard",
StandardButton::Apply => "Apply",
StandardButton::Close => "Close",
StandardButton::Abort => "Abort",
StandardButton::Retry => "Retry",
StandardButton::Ignore => "Ignore",
StandardButton::Help => "Help",
}
}
pub fn translated_label(&self) -> String {
match self {
StandardButton::Ok => tr!("common.button.ok"),
StandardButton::Cancel => tr!("common.button.cancel"),
StandardButton::Yes => tr!("common.button.yes"),
StandardButton::No => tr!("common.button.no"),
StandardButton::YesAll => tr!("common.button.yes_all"),
StandardButton::NoAll => tr!("common.button.no_all"),
StandardButton::Save => tr!("common.button.save"),
StandardButton::Discard => tr!("common.button.discard"),
StandardButton::Apply => tr!("common.button.apply"),
StandardButton::Close => tr!("common.button.close"),
StandardButton::Abort => tr!("common.button.abort"),
StandardButton::Retry => tr!("common.button.retry"),
StandardButton::Ignore => tr!("common.button.ignore"),
StandardButton::Help => tr!("common.button.help"),
}
}
}
pub struct MessageBox {
base: BaseWidget,
title: String,
text: String,
icon: MessageBoxIcon,
buttons: Vec<StandardButton>,
default_button: Option<StandardButton>,
modal: bool,
reveal: PropertyDriver,
new_visible: bool,
pub button_clicked: Signal1<StandardButton>,
pub accepted: GenericSignal,
pub rejected: GenericSignal,
}
impl MessageBox {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::MessageBox, geometry, "MessageBox"),
title: String::new(),
text: String::new(),
icon: MessageBoxIcon::NoIcon,
buttons: vec![StandardButton::Ok],
default_button: Some(StandardButton::Ok),
modal: true,
reveal: PropertyDriver::at(0.0, MotionSlot::Normal),
new_visible: false,
button_clicked: Signal1::new(),
accepted: GenericSignal::new(),
rejected: GenericSignal::new(),
}
}
pub fn question(geometry: Rect, title: impl Into<String>, text: impl Into<String>) -> Self {
let mut mb = Self::new(geometry);
mb.title = title.into();
mb.text = text.into();
mb.icon = MessageBoxIcon::Question;
mb.buttons = vec![StandardButton::Yes, StandardButton::No];
mb.default_button = Some(StandardButton::Yes);
mb
}
pub fn information(geometry: Rect, title: impl Into<String>, text: impl Into<String>) -> Self {
let mut mb = Self::new(geometry);
mb.title = title.into();
mb.text = text.into();
mb.icon = MessageBoxIcon::Information;
mb
}
pub fn warning(geometry: Rect, title: impl Into<String>, text: impl Into<String>) -> Self {
let mut mb = Self::new(geometry);
mb.title = title.into();
mb.text = text.into();
mb.icon = MessageBoxIcon::Warning;
mb
}
pub fn critical(geometry: Rect, title: impl Into<String>, text: impl Into<String>) -> Self {
let mut mb = Self::new(geometry);
mb.title = title.into();
mb.text = text.into();
mb.icon = MessageBoxIcon::Critical;
mb
}
pub fn title(&self) -> &str {
&self.title
}
pub fn text(&self) -> &str {
&self.text
}
pub fn icon(&self) -> MessageBoxIcon {
self.icon
}
pub fn buttons(&self) -> &[StandardButton] {
&self.buttons
}
pub fn default_button(&self) -> Option<StandardButton> {
self.default_button
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
self.base.request_redraw();
}
pub fn set_text(&mut self, text: impl Into<String>) {
self.text = text.into();
self.base.request_redraw();
}
pub fn set_icon(&mut self, icon: MessageBoxIcon) {
self.icon = icon;
self.base.request_redraw();
}
pub fn set_buttons(&mut self, buttons: Vec<StandardButton>) {
self.buttons = buttons;
self.base.request_redraw();
}
pub fn set_default_button(&mut self, btn: StandardButton) {
self.default_button = Some(btn);
self.base.request_redraw();
}
pub fn reveal_progress(&self) -> f32 {
self.reveal.value()
}
pub fn is_modal(&self) -> bool {
self.modal
}
pub fn set_modal(&mut self, modal: bool) {
self.modal = modal;
self.base.request_redraw();
}
pub fn click_button(&mut self, btn: StandardButton) {
self.button_clicked.emit(btn);
match btn {
StandardButton::Ok
| StandardButton::Yes
| StandardButton::Save
| StandardButton::Apply => {
self.accepted.emit();
}
_ => {
self.rejected.emit();
}
}
}
fn icon_symbol(&self) -> &'static str {
match self.icon {
MessageBoxIcon::Information => "ℹ",
MessageBoxIcon::Question => "?",
MessageBoxIcon::Warning => "⚠",
MessageBoxIcon::Critical => "✗",
MessageBoxIcon::NoIcon => "",
}
}
fn icon_color(&self) -> Color {
let token = match self.icon {
MessageBoxIcon::Information | MessageBoxIcon::Question => SemanticColor::Info,
MessageBoxIcon::Warning => SemanticColor::Warning,
MessageBoxIcon::Critical => SemanticColor::Error,
MessageBoxIcon::NoIcon => SemanticColor::Info,
};
crate::style::semantic_color(token).unwrap_or_else(|| self.icon_color_fallback())
}
fn icon_color_fallback(&self) -> Color {
match self.icon {
MessageBoxIcon::Information => Color::rgb(0, 120, 215),
MessageBoxIcon::Question => Color::rgb(0, 120, 215),
MessageBoxIcon::Warning => Color::rgb(255, 140, 0),
MessageBoxIcon::Critical => Color::rgb(196, 43, 28),
MessageBoxIcon::NoIcon => Color::rgb(0, 0, 0),
}
}
}
impl Widget for MessageBox {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
crate::core::Size::new(350, 150)
}
fn as_draw_mut(&mut self) -> Option<&mut dyn crate::widget::Draw> {
Some(self)
}
fn tick(&mut self, delta_ms: u32) -> bool {
let visible = self.base.is_visible();
if visible != self.new_visible {
self.new_visible = visible;
self.reveal.set_target(if visible { 1.0 } else { 0.0 });
}
self.reveal.tick(delta_ms)
}
fn is_animating(&self) -> bool {
self.reveal.is_moving()
}
impl_widget_property_hooks!();
}
impl WidgetProperties for MessageBox {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"title" => Ok(CapabilityValue::String(self.title().to_string())),
"text" => Ok(CapabilityValue::String(self.text().to_string())),
"icon" => Ok(CapabilityValue::String(message_box_icon_to_str(self.icon()).to_string())),
"modal" => Ok(CapabilityValue::Bool(self.is_modal())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"title" => {
self.set_title(expect_string(value)?);
Ok(())
}
"text" => {
self.set_text(expect_string(value)?);
Ok(())
}
"icon" => {
self.set_icon(expect_message_box_icon(value)?);
Ok(())
}
"modal" => {
self.set_modal(expect_bool(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["title", "text", "icon", "modal", BASE_PROPERTY_NAMES]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"set_text" | "set_title" | "set_icon" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl EventHandler for MessageBox {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button: 1 } => {
if let Some(index) = self.action_button_at(*pos) {
if let Some(activated) = self.buttons.get(index).copied() {
self.click_button(activated);
}
}
}
Event::KeyPress { key, .. } => {
if *key == 13 {
if let Some(btn) = self.default_button {
self.click_button(btn);
}
} else if *key == 27 {
if self.buttons.contains(&StandardButton::Cancel) {
self.click_button(StandardButton::Cancel);
} else if self.buttons.contains(&StandardButton::No) {
self.click_button(StandardButton::No);
} else if self.buttons.contains(&StandardButton::Close) {
self.click_button(StandardButton::Close);
}
}
}
_ => { }
}
}
}
pub(crate) const DIALOG_BUTTON_WIDTH: i32 = 80;
pub(crate) const DIALOG_BUTTON_SPACING: u32 = dimensions::BUTTON_ICON_SPACING;
pub(crate) fn action_button_hints(context: &RenderContext, label: &str) -> Hints {
let font = Font::default();
let line = context.measure_text(label, &font).height.max(1);
Hints::fixed(DIALOG_BUTTON_WIDTH as u32, line)
}
impl MessageBox {
fn frame_rect(&self, context: &RenderContext) -> Rect {
ControlMetrics::painted_box(self.base.geometry(), self.intrinsic_size(context))
}
fn intrinsic_size(&self, context: &RenderContext) -> Size {
let labels: Vec<String> =
self.buttons.iter().map(|button| button.translated_label()).collect();
let row = action_row_geometry(context, &labels, Rect::new(0, 0, 0, 0), false);
let row_height = dimensions::DIALOG_TITLE_BAR_HEIGHT.max(row.row.height);
let height = dimensions::DIALOG_TITLE_BAR_HEIGHT
.saturating_add(row_height)
.max(dimensions::DIALOG_MIN_HEIGHT);
Size::new(row.row.width.max(dimensions::DIALOG_MIN_WIDTH), height)
}
fn action_button_at(&self, pos: Point) -> Option<usize> {
let backend_size =
Size::new(self.base.geometry().width.max(1), self.base.geometry().height.max(1));
let mut backend = crate::render::SoftwarePaintBackend::new(backend_size, 1.0);
let context = RenderContext::new(&mut backend);
let rect = self.frame_rect(&context);
let body =
ControlMetrics::content_below_top_band(rect, dimensions::DIALOG_TITLE_BAR_HEIGHT);
let button_band = ControlMetrics::bottom_band(body, dimensions::DIALOG_BUTTON_HEIGHT);
let labels: Vec<String> =
self.buttons.iter().map(|button| button.translated_label()).collect();
let place = !self.buttons.is_empty();
action_row_geometry(&context, &labels, button_band, place).hit(pos)
}
}
impl Draw for MessageBox {
fn draw(&mut self, context: &mut RenderContext) {
let reveal = self.reveal.value();
if reveal <= 0.0 {
return;
}
if self.modal {
super::draw_modal_scrim_scaled(context, self.geometry(), reveal);
}
let rect = self.frame_rect(context);
let rect = super::scale_about_centre(
rect,
super::REVEAL_MIN_SCALE + (1.0 - super::REVEAL_MIN_SCALE) * reveal,
);
if rect.width == 0 || rect.height == 0 {
return;
}
let style = self.base.style().clone();
let theme = crate::style::resolved_theme_style("message_box");
let window_fill = {
let manager = crate::style::theme_manager();
manager.current_theme().map(|active| active.colors.background).unwrap_or(Color::WHITE)
};
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::rgb(40, 40, 40));
let surface = match style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
{
Some(resolved) if resolved != window_fill => resolved,
_ => window_fill.blend(&ink, 0.06),
};
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.filter(|resolved| *resolved != surface)
.unwrap_or_else(|| surface.blend(&ink, 0.45));
let title_bar = surface.blend(&ink, 0.08);
let button_fill = surface.blend(&ink, 0.12);
let primary = theme.as_ref().and_then(|t| t.background_color).unwrap_or(button_fill);
let primary_ink = primary.contrast_color();
let font = Font::default();
let radius = dimensions::DIALOG_RADIUS.min(rect.width / 2).min(rect.height / 2);
if radius > 0 {
context.fill_rounded_rect(rect, radius, surface);
context.draw_rounded_rect_stroke(rect, radius, border, 1);
} else {
context.fill_rect(rect, surface);
context.draw_rect(rect, border);
}
let title_bar_band = ControlMetrics::top_band(rect, dimensions::DIALOG_TITLE_BAR_HEIGHT);
context.fill_rect(title_bar_band, title_bar);
if !self.title.is_empty() {
let title_font = Font::default();
let title_line = context.text_line(title_bar_band, &title_font);
context.draw_text_fitted(
Rect::new(
rect.x + 8,
title_line.y,
rect.width.saturating_sub(16),
title_line.height.max(1),
),
&self.title,
&title_font,
ink,
HorizontalAlignment::Left,
);
}
let body =
ControlMetrics::content_below_top_band(rect, dimensions::DIALOG_TITLE_BAR_HEIGHT);
let icon_sym = self.icon_symbol();
let body_font = Font::default();
let body_line_h = context.measure_text("M", &body_font).height.max(1) as i32;
let icon_left = 12.min(body.width as i32);
let gutter = if icon_sym.is_empty() {
icon_left
} else {
let icon_metrics = context.measure_text(icon_sym, &body_font);
let icon_line = context.text_line(body, &body_font);
context.draw_text_fitted(
Rect::new(
body.x + icon_left,
icon_line.y,
icon_metrics.width.max(1),
icon_line.height.max(1),
),
icon_sym,
&body_font,
self.icon_color(),
HorizontalAlignment::Left,
);
icon_left + icon_metrics.width as i32 + 8
};
let button_band = ControlMetrics::bottom_band(body, dimensions::DIALOG_BUTTON_HEIGHT);
let labels: Vec<String> =
self.buttons.iter().map(|button| button.translated_label()).collect();
let row = action_row_geometry(context, &labels, button_band, !self.buttons.is_empty());
let message_area =
ControlMetrics::content_above_bottom_band(body, dimensions::DIALOG_BUTTON_HEIGHT);
let message_right = (message_area.width as i32 - row.leading_inset as i32).max(0);
let message_band = Rect::new(
message_area.x + gutter,
message_area.y,
(message_right - gutter).max(0) as u32,
message_area.height,
);
if !self.text.is_empty() {
let message_line = context.text_line(message_band, &body_font);
context.draw_text_fitted(
Rect::new(
message_band.x,
message_line.y,
message_band.width.max(1),
message_line.height.max(1),
),
&self.text,
&body_font,
ink,
HorizontalAlignment::Left,
);
}
debug_assert!(body_line_h > 0);
for (button, button_rect) in self.buttons.iter().zip(row.button_rects()) {
let is_default = self.default_button == Some(*button);
let bg = if is_default { primary } else { button_fill };
let fg = if is_default { primary_ink } else { ink };
context.fill_rect(*button_rect, bg);
context.draw_rect(*button_rect, border);
context.draw_text_line(
*button_rect,
&button.translated_label(),
&font,
fg,
HorizontalAlignment::Center,
);
}
}
}
pub(crate) struct ActionRowGeometry {
pub row: Rect,
pub buttons: Vec<Rect>,
pub leading_inset: u32,
}
impl ActionRowGeometry {
pub fn button_rects(&self) -> &[Rect] {
&self.buttons
}
pub fn hit(&self, pos: Point) -> Option<usize> {
self.buttons.iter().position(|button| button.contains_point(pos))
}
}
pub(crate) fn action_row_geometry(
context: &RenderContext,
labels: &[String],
band: Rect,
place: bool,
) -> ActionRowGeometry {
if labels.is_empty() {
return ActionRowGeometry { row: band, buttons: Vec::new(), leading_inset: 0 };
}
let children: Vec<ChildInfo> = labels
.iter()
.enumerate()
.map(|(index, label)| {
let leading = if index == 0 { 0 } else { DIALOG_BUTTON_SPACING };
ChildInfo::new(context_row_id(index), action_button_hints(context, label)).with_params(
LayoutParams::new().with_margins(EdgeOffsets::new(0, 0, 0, leading)),
)
})
.collect();
let mut layout = FlexLayout::with_params(
crate::layout::FlexDirection::Row,
crate::layout::FlexWrap::NoWrap,
JustifyContent::FlexEnd,
crate::layout::AlignItems::Stretch,
0,
0,
);
for child in &children {
layout.add_widget(child.id, child.params.stretch);
}
let row_extent = children
.iter()
.map(|child| child.bounds().width)
.fold(0u32, |total, width| total.saturating_add(width))
.max(1);
let measure = Rect::new(band.x, band.y, row_extent, band.height);
let mut placed: Vec<(ObjectId, Rect)> = Vec::with_capacity(children.len());
layout.arrange(measure, &children, &mut |id, rect| placed.push((id, rect)));
let shift = if place { (band.width as i32 - row_extent as i32).max(0) } else { 0 };
let mut buttons = Vec::with_capacity(children.len());
for child in &children {
let rect =
placed.iter().find(|(id, _)| *id == child.id).map(|(_, rect)| *rect).unwrap_or_else(
|| {
let size = child.bounds();
Rect::new(measure.x, band.y, size.width, band.height)
},
);
buttons.push(Rect::new(rect.x + shift, band.y, rect.width, band.height));
}
let first = buttons.first().copied().unwrap_or(band);
let last = buttons.last().copied().unwrap_or(band);
let row_left = first.x;
let row_right = last.x.saturating_add(last.width as i32);
let row = Rect::new(row_left, band.y, (row_right - row_left).max(0) as u32, band.height);
let leading_inset = if place { (row_left - band.x).max(0) as u32 } else { 0 };
ActionRowGeometry { row, buttons, leading_inset }
}
fn context_row_id(index: usize) -> ObjectId {
ROW_ID_BASE + index as u64
}
const ROW_ID_BASE: u64 = 0x1000_0000_0000_0000;
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{Point, Rect, Size};
use crate::event::Event;
use crate::render::SoftwarePaintBackend;
use crate::widget::svg::render_to_svg;
use std::sync::{Arc, Mutex};
#[cfg(feature = "i18n")]
#[test]
fn translated_label_resolves_through_the_catalogue() {
crate::i18n::init();
assert_eq!(
StandardButton::Ok.translated_label(),
"OK",
"a built-in button label must translate rather than echo its catalogue key"
);
assert_eq!(StandardButton::Cancel.translated_label(), "Cancel");
}
#[cfg(feature = "i18n")]
#[test]
fn translated_label_is_not_the_catalogue_key() {
crate::i18n::init();
let translated = StandardButton::Yes.translated_label();
assert_ne!(translated, "common.button.yes");
assert!(!translated.is_empty(), "an unknown key still yields the key itself");
}
#[cfg(not(feature = "i18n"))]
#[test]
fn translated_label_without_i18n_returns_the_key() {
assert_eq!(StandardButton::Ok.translated_label(), "common.button.ok");
}
#[test]
fn the_action_row_is_right_anchored_and_each_button_follows_its_sibling() {
let mut backend = SoftwarePaintBackend::new(Size::new(240, 120), 1.0);
let ctx = RenderContext::new(&mut backend);
let labels = vec!["OK".to_string(), "Cancel".to_string()];
let band = Rect::new(0, 92, 240, 28);
let row = action_row_geometry(&ctx, &labels, band, true);
assert_eq!(row.buttons.len(), 2, "one rect per label");
let last = row.buttons[1];
assert_eq!(
last.x + last.width as i32,
band.x + band.width as i32,
"the row must be anchored to the band's trailing edge, not to a count-derived offset"
);
let first = row.buttons[0];
assert_eq!(
last.x - (first.x + first.width as i32),
DIALOG_BUTTON_SPACING as i32,
"the gap between two buttons must be the declared spacing"
);
assert_eq!(
first.width, DIALOG_BUTTON_WIDTH as u32,
"a standard command keeps its declared width instead of growing into the band"
);
assert_eq!(
row.leading_inset,
(row.row.x - band.x) as u32,
"the inset a dialog's own content carries is measured from the band's leading edge"
);
assert_eq!(
band.x + row.leading_inset as i32,
row.row.x,
"the leading inset ends exactly where the row begins, so nothing can overlap it"
);
assert_eq!(
row.leading_inset + row.row.width,
band.width,
"the inset and the row tile the band, leaving no gap between them"
);
}
#[test]
fn a_row_wider_than_its_band_keeps_its_widths_and_its_leading_edge() {
let mut backend = SoftwarePaintBackend::new(Size::new(240, 120), 1.0);
let ctx = RenderContext::new(&mut backend);
let labels: Vec<String> = (0..6).map(|i| format!("Button {i}")).collect();
let band = Rect::new(0, 92, 120, 28);
let row = action_row_geometry(&ctx, &labels, band, true);
assert_eq!(
row.row.x, band.x,
"an oversized row is anchored to the band's leading edge, not centred on it"
);
for (index, button) in row.button_rects().iter().enumerate() {
assert_eq!(
button.width, DIALOG_BUTTON_WIDTH as u32,
"a squeezed band must not shrink the buttons: an elided command is unreadable"
);
if index > 0 {
let previous = row.button_rects()[index - 1];
assert_eq!(
button.x - (previous.x + previous.width as i32),
DIALOG_BUTTON_SPACING as i32,
"the gap between two buttons survives an oversized row"
);
}
}
}
#[test]
fn a_press_hits_the_button_that_was_drawn_there() {
let mut backend = SoftwarePaintBackend::new(Size::new(240, 120), 1.0);
let ctx = RenderContext::new(&mut backend);
let labels = vec!["OK".to_string(), "Cancel".to_string()];
let band = Rect::new(0, 92, 240, 28);
let row = action_row_geometry(&ctx, &labels, band, true);
for (index, button) in row.button_rects().iter().enumerate() {
let centre =
Point::new(button.x + button.width as i32 / 2, button.y + button.height as i32 / 2);
assert_eq!(row.hit(centre), Some(index), "the centre of {button:?} belongs to it");
}
assert_eq!(row.hit(Point::new(band.x + 1, band.y + band.height as i32 / 2)), None);
}
#[test]
fn test_default_creation() {
let mb = MessageBox::new(Rect::new(100, 100, 300, 150));
assert_eq!(mb.kind(), WidgetKind::MessageBox);
assert_eq!(mb.geometry(), Rect::new(100, 100, 300, 150));
assert!(mb.title().is_empty());
assert!(mb.text().is_empty());
assert_eq!(mb.icon(), MessageBoxIcon::NoIcon);
assert_eq!(mb.buttons(), &[StandardButton::Ok]);
assert_eq!(mb.default_button(), Some(StandardButton::Ok));
assert!(mb.is_modal());
assert!(mb.is_visible());
assert!(mb.is_enabled());
}
#[test]
fn test_set_title_and_text() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
assert!(mb.title().is_empty());
assert!(mb.text().is_empty());
mb.set_title("Warning");
assert_eq!(mb.title(), "Warning");
mb.set_text("Are you sure?");
assert_eq!(mb.text(), "Are you sure?");
}
#[test]
fn test_set_icon() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
assert_eq!(mb.icon(), MessageBoxIcon::NoIcon);
mb.set_icon(MessageBoxIcon::Information);
assert_eq!(mb.icon(), MessageBoxIcon::Information);
mb.set_icon(MessageBoxIcon::Warning);
assert_eq!(mb.icon(), MessageBoxIcon::Warning);
mb.set_icon(MessageBoxIcon::Critical);
assert_eq!(mb.icon(), MessageBoxIcon::Critical);
mb.set_icon(MessageBoxIcon::Question);
assert_eq!(mb.icon(), MessageBoxIcon::Question);
mb.set_icon(MessageBoxIcon::NoIcon);
assert_eq!(mb.icon(), MessageBoxIcon::NoIcon);
}
#[test]
fn message_box_icon_is_reachable_through_the_property_route() {
use crate::widget::capability::types::CapabilityValue;
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
assert_eq!(
mb.get("icon").expect("`icon` must be readable"),
CapabilityValue::String("none".to_string())
);
for (token, expected) in [
("information", MessageBoxIcon::Information),
("question", MessageBoxIcon::Question),
("warning", MessageBoxIcon::Warning),
("critical", MessageBoxIcon::Critical),
("none", MessageBoxIcon::NoIcon),
] {
mb.set("icon", CapabilityValue::String(token.to_string()))
.unwrap_or_else(|e| panic!("set(\"icon\", {token:?}) must be accepted: {e:?}"));
assert_eq!(mb.icon(), expected, "token {token:?} must select {expected:?}");
assert_eq!(mb.get("icon").unwrap(), CapabilityValue::String(token.to_string()));
}
mb.set("icon", CapabilityValue::String("error".to_string()))
.expect("`error` must be accepted as a synonym for `critical`");
assert_eq!(mb.icon(), MessageBoxIcon::Critical);
assert!(
mb.property_names().contains(&"icon"),
"`icon` must appear in property_names, or the schema promises a property the \
contract does not publish"
);
assert!(
mb.set("icon", CapabilityValue::String("chartreuse".to_string())).is_err(),
"an unknown icon token must be refused"
);
assert!(mb.set("icon", CapabilityValue::Bool(true)).is_err());
}
#[test]
fn message_box_constructors_carry_their_severity() {
let cases: [(MessageBox, MessageBoxIcon); 4] = [
(
MessageBox::warning(Rect::new(0, 0, 300, 150), "Disk almost full", "12 MB left"),
MessageBoxIcon::Warning,
),
(
MessageBox::critical(Rect::new(0, 0, 300, 150), "Save failed", "Disk is full"),
MessageBoxIcon::Critical,
),
(
MessageBox::information(Rect::new(0, 0, 300, 150), "Done", "Export finished"),
MessageBoxIcon::Information,
),
(
MessageBox::question(
Rect::new(0, 0, 300, 150),
"Discard?",
"This cannot be undone",
),
MessageBoxIcon::Question,
),
];
for (mb, expected) in cases {
assert_eq!(mb.icon(), expected);
assert_eq!(
mb.get("icon").unwrap(),
CapabilityValue::String(message_box_icon_to_str(expected).to_string())
);
}
}
#[test]
fn test_set_buttons() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
assert_eq!(mb.buttons(), &[StandardButton::Ok]);
mb.set_buttons(vec![StandardButton::Ok, StandardButton::Cancel]);
assert_eq!(mb.buttons(), &[StandardButton::Ok, StandardButton::Cancel]);
mb.set_buttons(vec![StandardButton::Yes, StandardButton::No]);
assert_eq!(mb.buttons(), &[StandardButton::Yes, StandardButton::No]);
mb.set_buttons(vec![StandardButton::Yes, StandardButton::No, StandardButton::Cancel]);
assert_eq!(mb.buttons().len(), 3);
}
#[test]
fn test_default_button() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
assert_eq!(mb.default_button(), Some(StandardButton::Ok));
mb.set_default_button(StandardButton::Cancel);
assert_eq!(mb.default_button(), Some(StandardButton::Cancel));
}
#[test]
fn test_accepted_signal() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
let fired = Arc::new(Mutex::new(false));
mb.accepted.connect({
let fired = Arc::clone(&fired);
move || {
*fired.lock().unwrap() = true;
}
});
mb.click_button(StandardButton::Ok);
assert!(*fired.lock().unwrap());
}
#[test]
fn test_rejected_signal() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
let fired = Arc::new(Mutex::new(false));
mb.rejected.connect({
let fired = Arc::clone(&fired);
move || {
*fired.lock().unwrap() = true;
}
});
mb.click_button(StandardButton::Cancel);
assert!(*fired.lock().unwrap());
}
#[test]
fn test_button_clicked_signal() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
let captured = Arc::new(Mutex::new(None::<StandardButton>));
mb.button_clicked.connect({
let captured = Arc::clone(&captured);
move |val: Arc<StandardButton>| {
*captured.lock().unwrap() = Some(*val);
}
});
mb.click_button(StandardButton::Yes);
assert_eq!(*captured.lock().unwrap(), Some(StandardButton::Yes));
}
#[test]
fn test_click_button_accept_reject_pattern() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
let accepted = Arc::new(Mutex::new(false));
let rejected = Arc::new(Mutex::new(false));
mb.accepted.connect({
let accepted = Arc::clone(&accepted);
move || {
*accepted.lock().unwrap() = true;
}
});
mb.rejected.connect({
let rejected = Arc::clone(&rejected);
move || {
*rejected.lock().unwrap() = true;
}
});
mb.click_button(StandardButton::Ok);
assert!(*accepted.lock().unwrap());
assert!(!*rejected.lock().unwrap());
*accepted.lock().unwrap() = false;
mb.click_button(StandardButton::Cancel);
assert!(!*accepted.lock().unwrap());
assert!(*rejected.lock().unwrap());
}
#[test]
fn test_factory_constructors() {
let info = MessageBox::information(Rect::new(0, 0, 300, 150), "Information", "File saved.");
assert_eq!(info.title(), "Information");
assert_eq!(info.text(), "File saved.");
assert_eq!(info.icon(), MessageBoxIcon::Information);
assert_eq!(info.buttons(), &[StandardButton::Ok]);
assert_eq!(info.default_button(), Some(StandardButton::Ok));
let warn = MessageBox::warning(Rect::new(0, 0, 300, 150), "Warning", "Low disk space");
assert_eq!(warn.title(), "Warning");
assert_eq!(warn.text(), "Low disk space");
assert_eq!(warn.icon(), MessageBoxIcon::Warning);
let err = MessageBox::critical(Rect::new(0, 0, 300, 150), "Error", "Operation failed");
assert_eq!(err.title(), "Error");
assert_eq!(err.text(), "Operation failed");
assert_eq!(err.icon(), MessageBoxIcon::Critical);
let q = MessageBox::question(Rect::new(0, 0, 300, 150), "Question", "Continue?");
assert_eq!(q.title(), "Question");
assert_eq!(q.text(), "Continue?");
assert_eq!(q.icon(), MessageBoxIcon::Question);
assert_eq!(q.buttons(), &[StandardButton::Yes, StandardButton::No]);
assert_eq!(q.default_button(), Some(StandardButton::Yes));
}
#[test]
fn test_geometry_delegation() {
let mut mb = MessageBox::new(Rect::new(10, 20, 300, 150));
assert_eq!(mb.geometry(), Rect::new(10, 20, 300, 150));
mb.set_geometry(Rect::new(0, 0, 400, 200));
assert_eq!(mb.geometry(), Rect::new(0, 0, 400, 200));
assert_eq!(mb.geometry(), Rect::new(0, 0, 400, 200));
assert_eq!(mb.position(), Point::new(0, 0));
assert_eq!(mb.size(), crate::core::Size::new(400, 200));
}
#[test]
fn test_widget_id_and_kind() {
let mb = MessageBox::new(Rect::new(0, 0, 300, 150));
assert_eq!(mb.kind(), WidgetKind::MessageBox);
assert_ne!(mb.id(), 0);
let mb2 = MessageBox::new(Rect::new(0, 0, 200, 100));
assert_ne!(mb.id(), mb2.id());
}
#[test]
fn test_svg_output() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
mb.show();
while mb.tick(1000) {}
let svg = render_to_svg(&mut mb);
assert!(svg.starts_with("<svg"));
assert!(svg.contains("xmlns=\"http://www.w3.org/2000/svg\""));
assert!(svg.contains("width=\"300\""));
assert!(svg.contains("height=\"150\""));
assert!(svg.contains("rx=\""), "the shown box paints its frame: {svg}");
assert!(
crate::widget::svg::text_ink_box(&svg).is_some(),
"the default OK button paints its label: {svg}"
);
let mut mb2 = MessageBox::new(Rect::new(0, 0, 400, 200));
mb2.set_title("Test Title");
mb2.set_text("Hello");
mb2.set_icon(MessageBoxIcon::Warning);
mb2.show();
while mb2.tick(1000) {}
let svg2 = render_to_svg(&mut mb2);
assert!(svg2.starts_with("<svg"));
}
#[test]
fn showing_a_message_box_reveals_it() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
assert_eq!(mb.reveal_progress(), 0.0, "a fresh box is hidden");
assert!(!mb.is_animating(), "and owes no frames");
let hidden = render_to_svg(&mut mb);
assert!(
!hidden.contains("rx=\"") && crate::widget::svg::text_ink_box(&hidden).is_none(),
"a hidden box paints nothing: {hidden}"
);
mb.show();
assert!(mb.tick(20), "still revealing after one step");
let mid = mb.reveal_progress();
assert!(mid > 0.0 && mid < 1.0, "the box must pass through an interior reveal (got {mid})");
let mid_width = frame_width(&render_to_svg(&mut mb));
while mb.tick(1000) {}
assert_eq!(mb.reveal_progress(), 1.0, "and settle fully shown");
let settled_width = frame_width(&render_to_svg(&mut mb));
assert!(
mid_width > 0 && mid_width < settled_width,
"a revealing frame must be smaller than a settled one: mid={mid_width} settled={settled_width}"
);
mb.hide();
assert!(mb.tick(20), "hiding owes frames too");
while mb.tick(1000) {}
assert_eq!(mb.reveal_progress(), 0.0, "a hidden box settles closed");
}
fn frame_width(svg: &str) -> u32 {
svg.split("<rect ")
.filter(|chunk| chunk.contains("rx=\""))
.filter_map(|chunk| {
let w = chunk.split("width=\"").nth(1)?;
w.split('"').next()?.parse::<u32>().ok()
})
.max()
.unwrap_or(0)
}
#[test]
fn test_modality_setting() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
assert!(mb.is_modal());
mb.set_modal(false);
assert!(!mb.is_modal());
mb.set_modal(true);
assert!(mb.is_modal());
}
#[test]
fn test_disabled_state_blocks_events() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
mb.set_enabled(false);
assert!(!mb.is_enabled());
let accepted_fired = Arc::new(Mutex::new(false));
mb.accepted.connect({
let accepted_fired = Arc::clone(&accepted_fired);
move || {
*accepted_fired.lock().unwrap() = true;
}
});
mb.handle_event(&Event::KeyPress { key: 13, modifiers: 0 });
assert!(!*accepted_fired.lock().unwrap());
mb.set_enabled(true);
mb.handle_event(&Event::KeyPress { key: 13, modifiers: 0 });
assert!(*accepted_fired.lock().unwrap());
}
#[test]
fn test_keyboard_enter_triggers_default_button() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
mb.set_buttons(vec![StandardButton::Yes, StandardButton::No]);
mb.set_default_button(StandardButton::Yes);
let captured = Arc::new(Mutex::new(None::<StandardButton>));
mb.button_clicked.connect({
let captured = Arc::clone(&captured);
move |val: Arc<StandardButton>| {
*captured.lock().unwrap() = Some(*val);
}
});
mb.handle_event(&Event::KeyPress { key: 13, modifiers: 0 });
assert_eq!(*captured.lock().unwrap(), Some(StandardButton::Yes));
}
#[test]
fn test_keyboard_escape_triggers_cancel() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
mb.set_buttons(vec![StandardButton::Ok, StandardButton::Cancel]);
let captured = Arc::new(Mutex::new(None::<StandardButton>));
mb.button_clicked.connect({
let captured = Arc::clone(&captured);
move |val: Arc<StandardButton>| {
*captured.lock().unwrap() = Some(*val);
}
});
mb.handle_event(&Event::KeyPress { key: 27, modifiers: 0 });
assert_eq!(*captured.lock().unwrap(), Some(StandardButton::Cancel));
}
#[test]
fn test_keyboard_escape_falls_back_to_no_then_close() {
let mut mb = MessageBox::new(Rect::new(0, 0, 300, 150));
mb.set_buttons(vec![StandardButton::Yes, StandardButton::No]);
let captured = Arc::new(Mutex::new(None::<StandardButton>));
mb.button_clicked.connect({
let captured = Arc::clone(&captured);
move |val: Arc<StandardButton>| {
*captured.lock().unwrap() = Some(*val);
}
});
mb.handle_event(&Event::KeyPress { key: 27, modifiers: 0 });
assert_eq!(*captured.lock().unwrap(), Some(StandardButton::No));
let mut mb2 = MessageBox::new(Rect::new(0, 0, 300, 150));
mb2.set_buttons(vec![StandardButton::Ok]);
mb2.set_default_button(StandardButton::Ok);
let captured2 = Arc::new(Mutex::new(false));
mb2.button_clicked.connect({
let captured2 = Arc::clone(&captured2);
move |_: Arc<StandardButton>| {
*captured2.lock().unwrap() = true;
}
});
mb2.handle_event(&Event::KeyPress { key: 27, modifiers: 0 });
assert!(!*captured2.lock().unwrap());
}
#[test]
fn test_standard_button_labels() {
assert_eq!(StandardButton::Ok.label(), "OK");
assert_eq!(StandardButton::Cancel.label(), "Cancel");
assert_eq!(StandardButton::Yes.label(), "Yes");
assert_eq!(StandardButton::No.label(), "No");
assert_eq!(StandardButton::Save.label(), "Save");
assert_eq!(StandardButton::Apply.label(), "Apply");
assert_eq!(StandardButton::Close.label(), "Close");
assert_eq!(StandardButton::Abort.label(), "Abort");
assert_eq!(StandardButton::Retry.label(), "Retry");
assert_eq!(StandardButton::Ignore.label(), "Ignore");
assert_eq!(StandardButton::Help.label(), "Help");
assert_eq!(StandardButton::YesAll.label(), "Yes to All");
assert_eq!(StandardButton::NoAll.label(), "No to All");
assert_eq!(StandardButton::Discard.label(), "Discard");
}
}