use crate::core::{Color, Font, HorizontalAlignment, ObjectId, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::GenericSignal;
use crate::style::{MotionSlot, PropertyDriver};
use crate::widget::capability::coercion::expect_string;
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};
use crate::{impl_widget_property_hooks, property_names_of};
const DIALOG_TITLE_BAR_HEIGHT: u32 = dimensions::DIALOG_TITLE_BAR_HEIGHT;
const DIALOG_REVEAL_MIN_SCALE: f32 = super::REVEAL_MIN_SCALE;
pub struct Dialog {
base: BaseWidget,
title: String,
content_widget: Option<ObjectId>,
modal: bool,
reveal: PropertyDriver,
pub accepted: GenericSignal,
pub rejected: GenericSignal,
pub opened: GenericSignal,
pub closed: GenericSignal,
}
impl Dialog {
pub fn new(geometry: Rect) -> Self {
Self::with_title(String::new(), geometry)
}
pub fn with_title(title: impl Into<String>, geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Dialog, geometry, "Dialog"),
title: title.into(),
content_widget: None,
modal: true,
reveal: PropertyDriver::at(0.0, MotionSlot::Normal),
accepted: GenericSignal::new(),
rejected: GenericSignal::new(),
opened: GenericSignal::new(),
closed: GenericSignal::new(),
}
}
pub fn title(&self) -> &str {
&self.title
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
self.base.request_redraw();
}
pub fn content_widget(&self) -> Option<ObjectId> {
self.content_widget
}
pub fn set_content_widget(&mut self, widget: Option<ObjectId>) {
if let Some(old) = self.content_widget {
self.base.remove_child(old);
}
self.content_widget = widget;
if let Some(id) = widget {
self.base.add_child(id);
let _ = crate::widget::runtime::with_widget_mut(id, |child| {
child.set_parent(Some(self.id()));
});
}
self.base.request_redraw();
}
pub fn is_modal(&self) -> bool {
self.modal
}
pub fn set_modal(&mut self, modal: bool) {
self.modal = modal;
}
pub fn open(&mut self) {
self.show();
self.reveal.set_target(1.0);
if self.modal {
let _ = crate::widget::runtime::enter_modal(self.id());
}
self.opened.emit();
}
pub fn close(&mut self) {
let _ = crate::widget::runtime::exit_modal(self.id());
self.hide();
self.reveal.set_target(0.0);
self.closed.emit();
}
pub fn reveal_progress(&self) -> f32 {
self.reveal.value()
}
pub fn tick(&mut self, delta_ms: u32) -> bool {
self.reveal.tick(delta_ms)
}
pub fn is_animating(&self) -> bool {
self.reveal.is_moving()
}
pub fn accept(&mut self) {
self.accepted.emit();
self.close();
}
pub fn reject(&mut self) {
self.rejected.emit();
self.close();
}
fn frame_rect(&self) -> Rect {
ControlMetrics::painted_box(
self.base.geometry(),
Size::new(dimensions::DIALOG_MIN_WIDTH, dimensions::DIALOG_MIN_HEIGHT),
)
}
pub fn content_rect(&self) -> Rect {
let rect = self.frame_rect();
if self.title.is_empty() {
return rect;
}
let inset = DIALOG_TITLE_BAR_HEIGHT.min(rect.height);
Rect::new(rect.x, rect.y + inset as i32, rect.width, rect.height - inset)
}
}
impl Widget for Dialog {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
Size::new(320, 240)
}
fn tick(&mut self, delta_ms: u32) -> bool {
Dialog::tick(self, delta_ms)
}
fn is_animating(&self) -> bool {
Dialog::is_animating(self)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for Dialog {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"title" => Ok(CapabilityValue::String(self.title().to_string())),
"modal" => Ok(CapabilityValue::Bool(self.is_modal())),
"has_content" => Ok(CapabilityValue::Bool(self.content_widget().is_some())),
_ => 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(())
}
"modal" => match value {
CapabilityValue::Bool(v) => {
self.set_modal(v);
Ok(())
}
_ => Err(CapabilityAccessError::TypeMismatch),
},
"has_content" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["title", "modal", "has_content", BASE_PROPERTY_NAMES]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"accept" => {
self.accepted.emit();
Ok(())
}
"reject" => {
self.rejected.emit();
Ok(())
}
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl EventHandler for Dialog {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { button: 1, .. } => self.base.set_mouse_pressed(true),
Event::MouseRelease { button: 1, .. } => self.base.set_mouse_pressed(false),
_ => { }
}
}
}
impl Draw for Dialog {
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 resting = self.frame_rect();
if resting.width == 0 || resting.height == 0 {
return;
}
let style = self.base.style().clone();
let theme = crate::style::resolved_theme_style("dialog");
let window_fill = {
let manager = crate::style::theme_manager();
manager.current_theme().map(|active| active.colors.background).unwrap_or(Color::WHITE)
};
let themed_surface = theme.as_ref().and_then(|t| t.background_color);
let themed_ink = theme.as_ref().and_then(|t| t.text_color);
let themed_border = theme.as_ref().and_then(|t| t.border_color);
let ink = style.text_color.or(themed_ink).unwrap_or(Color::rgb(40, 40, 40));
let surface = match style.background_color.or(themed_surface) {
Some(resolved) if resolved != window_fill => resolved,
_ => window_fill.blend(&ink, 0.06),
};
let border = style
.border_color
.or(themed_border)
.filter(|resolved| *resolved != surface)
.unwrap_or_else(|| surface.blend(&ink, 0.45));
let title_bar = surface.blend(&ink, 0.08);
let scale = DIALOG_REVEAL_MIN_SCALE + (1.0 - DIALOG_REVEAL_MIN_SCALE) * reveal;
let rect = super::scale_about_centre(resting, scale);
if rect.width == 0 || rect.height == 0 {
return;
}
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);
}
if self.title.is_empty() {
return;
}
let bar = ControlMetrics::top_band(rect, DIALOG_TITLE_BAR_HEIGHT);
if bar.height == 0 {
return;
}
context.fill_rect(bar, title_bar);
context.draw_line(
Point::new(bar.x, bar.y + bar.height as i32),
Point::new(bar.x + bar.width as i32, bar.y + bar.height as i32),
border,
);
let title_font = Font::default();
let band = ControlMetrics::band_inset(bar, 0);
let title_line = context.text_line(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,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
fn dialog() -> Dialog {
Dialog::with_title("Settings", Rect::new(0, 0, 400, 300))
}
#[test]
fn dialog_creation_defaults() {
let d = Dialog::new(Rect::new(0, 0, 400, 300));
assert_eq!(d.kind(), WidgetKind::Dialog);
assert!(d.title().is_empty());
assert!(d.is_modal());
assert!(d.content_widget().is_none());
}
#[test]
fn dialog_title_and_modal_roundtrip() {
let mut d = dialog();
assert_eq!(d.title(), "Settings");
d.set_title("Preferences");
assert_eq!(d.title(), "Preferences");
d.set_modal(false);
assert!(!d.is_modal());
}
#[test]
fn dialog_accept_and_reject_emit_their_signals() {
let mut d = dialog();
let accepted = Arc::new(AtomicUsize::new(0));
let rejected = Arc::new(AtomicUsize::new(0));
let a = accepted.clone();
let r = rejected.clone();
d.accepted.connect(move || {
a.fetch_add(1, Ordering::SeqCst);
});
d.rejected.connect(move || {
r.fetch_add(1, Ordering::SeqCst);
});
d.accept();
assert_eq!(accepted.load(Ordering::SeqCst), 1);
assert_eq!(rejected.load(Ordering::SeqCst), 0);
d.reject();
assert_eq!(rejected.load(Ordering::SeqCst), 1);
}
#[test]
fn dialog_open_close_emit_lifecycle_signals() {
let mut d = dialog();
let opened = Arc::new(AtomicUsize::new(0));
let closed = Arc::new(AtomicUsize::new(0));
let o = opened.clone();
let c = closed.clone();
d.opened.connect(move || {
o.fetch_add(1, Ordering::SeqCst);
});
d.closed.connect(move || {
c.fetch_add(1, Ordering::SeqCst);
});
d.open();
assert!(d.is_visible());
assert_eq!(opened.load(Ordering::SeqCst), 1);
d.close();
assert!(!d.is_visible());
assert_eq!(closed.load(Ordering::SeqCst), 1);
}
#[test]
fn dialog_content_rect_insets_only_when_titled() {
let titleless = Dialog::new(Rect::new(0, 0, 160, 100));
assert_eq!(titleless.content_rect(), Rect::new(0, 0, 160, 100));
let titled = Dialog::with_title("T", Rect::new(0, 0, 160, 100));
assert_eq!(
titled.content_rect(),
Rect::new(0, DIALOG_TITLE_BAR_HEIGHT as i32, 160, 100 - DIALOG_TITLE_BAR_HEIGHT)
);
}
#[test]
fn dialog_with_a_title_paints_chrome() {
let _theme_guard = crate::style::theme_test_guard();
let mut titleless = Dialog::new(Rect::new(0, 0, 160, 100));
let mut titled = Dialog::with_title("Details".to_string(), Rect::new(0, 0, 160, 100));
for dialog in [&mut titleless, &mut titled] {
dialog.open();
while dialog.tick(1000) {}
}
let plain = crate::widget::svg::render_to_svg(&mut titleless);
let decorated = crate::widget::svg::render_to_svg(&mut titled);
assert_ne!(plain, decorated);
let (left, top, right, bottom) = crate::widget::svg::text_ink_box(&decorated)
.unwrap_or_else(|| panic!("a titled dialog must paint its title: {decorated}"));
assert!(right > left, "the title laid down ink: {left}..{right}");
assert!(
(0..DIALOG_TITLE_BAR_HEIGHT as i32).contains(&top)
&& bottom <= DIALOG_TITLE_BAR_HEIGHT as i32,
"the title must sit on the title strip, got {top}..{bottom}"
);
assert!(
crate::widget::svg::text_ink_box(&plain).is_none(),
"a titleless dialog paints no title ink: {plain}"
);
}
#[test]
fn a_modal_dialog_dims_the_page_behind_it() {
use crate::style::LayerColor;
let _theme_guard = crate::style::theme_test_guard();
crate::widget::census::install_preset_appearances();
for appearance in [crate::theme::AppearanceMode::Light, crate::theme::AppearanceMode::Dark]
{
crate::theme::global_theme_manager().set_appearance(appearance);
let page = crate::style::theme_manager()
.current_theme()
.expect("a preset is active")
.colors
.background;
let scrim = crate::style::layer_color(LayerColor::Scrim)
.expect("the preset defines a scrim role");
let composited = page.blend(&scrim, scrim.a as f32 / 255.0);
assert!(
composited.luminance() < page.luminance(),
"the {appearance:?} scrim must dim the page: {page:?} -> {composited:?}"
);
}
let mut modal = Dialog::with_title("Hi", Rect::new(0, 0, 200, 120));
modal.set_modal(true);
modal.open();
while modal.tick(1000) {}
let with_scrim = crate::widget::svg::render_to_svg(&mut modal);
let mut modeless = Dialog::with_title("Hi", Rect::new(0, 0, 200, 120));
modeless.set_modal(false);
modeless.open();
while modeless.tick(1000) {}
let without_scrim = crate::widget::svg::render_to_svg(&mut modeless);
assert_ne!(
with_scrim, without_scrim,
"modality must be visible, or the dialog lies about whether the page is inert"
);
}
#[test]
fn opening_a_dialog_reveals_it() {
use crate::widget::svg::render_to_svg;
let mut dialog = Dialog::with_title("Hi", Rect::new(0, 0, 200, 120));
assert_eq!(dialog.reveal_progress(), 0.0, "a fresh dialog is hidden");
assert!(!dialog.is_animating(), "and owes no frames");
dialog.open();
assert!(dialog.is_visible(), "the logical state answers at once");
assert!(dialog.is_animating(), "while the drawn frame owes frames");
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)
}
assert!(dialog.tick(60), "still growing after one step");
let mid = dialog.reveal_progress();
assert!(
mid > 0.0 && mid < 1.0,
"the dialog must pass through an interior reveal (got {mid})"
);
let mid_width = frame_width(&render_to_svg(&mut dialog));
while dialog.tick(60) {}
assert_eq!(dialog.reveal_progress(), 1.0, "and settle fully shown");
let settled_width = frame_width(&render_to_svg(&mut dialog));
assert!(
mid_width > 0 && mid_width < settled_width,
"a revealing frame must be smaller than a settled one: mid={mid_width} settled={settled_width}"
);
}
}