use std::sync::Arc;
use rosace_core::types::Size;
use rosace_layout::Constraints;
use rosace_render::Color;
use rosace_shader::ShaderMaterial;
use rosace_state::Atom;
use super::{Widget, LayoutCtx, PaintCtx, BoxedWidget};
use super::button::{Button, ButtonVariant};
use super::column::Column;
use super::container::draw_rounded_rect_pub;
use super::material::{resolve_material, DialogMaterial};
use super::overlay::{
FocusBehavior, InputBehavior, LayerPosition, OverlayEntry, ScrimConfig, push_overlay,
};
use super::padding::EdgeInsets;
use super::row::Row;
use super::text::Text;
use rosace_layout::MainAxisAlignment;
type Action = (String, ButtonVariant, Arc<dyn Fn() + Send + Sync>);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum DialogPresentation {
#[default]
Modal,
NonModal,
FullPage,
}
pub struct Dialog {
pub title: String,
pub message: Option<String>,
pub width: f32,
pub radius: f32,
pub presentation: DialogPresentation,
background: Option<Color>,
color: Option<Color>,
material: Option<ShaderMaterial>,
actions: Vec<Action>,
}
impl Dialog {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
message: None,
width: 340.0,
radius: 12.0,
presentation: DialogPresentation::default(),
background: None,
color: None,
material: None,
actions: Vec::new(),
}
}
pub fn message(mut self, m: impl Into<String>) -> Self { self.message = Some(m.into()); self }
pub fn width(mut self, w: f32) -> Self { self.width = w; self }
pub fn radius(mut self, r: f32) -> Self { self.radius = r; self }
pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
pub fn material(mut self, m: ShaderMaterial) -> Self { self.material = Some(m); self }
pub fn modal(mut self) -> Self { self.presentation = DialogPresentation::Modal; self }
pub fn non_modal(mut self) -> Self { self.presentation = DialogPresentation::NonModal; self }
pub fn full_page(mut self) -> Self { self.presentation = DialogPresentation::FullPage; self }
pub fn action(mut self, label: impl Into<String>, f: impl Fn() + Send + Sync + 'static) -> Self {
self.actions.push((label.into(), ButtonVariant::Secondary, Arc::new(f)));
self
}
pub fn primary_action(mut self, label: impl Into<String>, f: impl Fn() + Send + Sync + 'static) -> Self {
self.actions.push((label.into(), ButtonVariant::Primary, Arc::new(f)));
self
}
pub fn destructive_action(mut self, label: impl Into<String>, f: impl Fn() + Send + Sync + 'static) -> Self {
self.actions.push((label.into(), ButtonVariant::Danger, Arc::new(f)));
self
}
pub fn overlay_entry(self, on_dismiss: impl Fn() + Send + Sync + 'static) -> OverlayEntry {
match self.presentation {
DialogPresentation::Modal => {
OverlayEntry::new(LayerPosition::Centered, self)
.input(InputBehavior::Block)
.focus(FocusBehavior::Trap)
.scrim(ScrimConfig {
color: Color::rgba(0, 0, 0, 160),
on_tap: Some(Arc::new(on_dismiss)),
exclude_rect: None,
})
}
DialogPresentation::NonModal => {
OverlayEntry::new(LayerPosition::Centered, self)
.input(InputBehavior::PassThrough)
.focus(FocusBehavior::PassThrough)
}
DialogPresentation::FullPage => {
OverlayEntry::new(LayerPosition::Fill, self)
.input(InputBehavior::Block)
.focus(FocusBehavior::Trap)
.scrim(ScrimConfig {
color: Color::TRANSPARENT,
on_tap: Some(Arc::new(on_dismiss)),
exclude_rect: None,
})
}
}
}
pub fn emit(self, open: &Atom<bool>) {
if !open.get() { return; }
let close = open.clone();
push_overlay(self.overlay_entry(move || close.set(false)));
}
fn build_inner(&self) -> BoxedWidget {
let mut title = Text::title(&self.title);
if let Some(c) = self.color { title = title.color(c); }
let mut col = Column::new()
.spacing(12.0)
.child(title);
if let Some(msg) = &self.message {
let mut msg_text = Text::caption(msg);
if let Some(c) = self.color { msg_text = msg_text.color(c); }
col = col.child(msg_text);
}
if !self.actions.is_empty() {
let mut actions = Row::new()
.spacing(8.0)
.main_axis_alignment(MainAxisAlignment::End);
for (label, variant, cb) in &self.actions {
let cb = Arc::clone(cb);
actions = actions.child(
Button::new(label.clone())
.variant(*variant)
.on_press(move || cb()),
);
}
col = col.child(actions);
}
Box::new(col)
}
}
const PADDING: f32 = 20.0;
impl Widget for Dialog {
fn layout(&self, ctx: &LayoutCtx) -> Size {
if self.presentation == DialogPresentation::FullPage {
return ctx.constraints.constrain(Size {
width: super::avail_w(ctx.constraints),
height: super::avail_h(ctx.constraints),
});
}
let inner = self.build_inner();
let inner_c = Constraints::loose(self.width - PADDING * 2.0, f32::INFINITY);
let inner_size = inner.layout(&ctx.with_constraints(inner_c));
ctx.constraints.constrain(Size {
width: self.width,
height: inner_size.height + PADDING * 2.0,
})
}
fn paint(&self, ctx: &mut PaintCtx) {
ctx.semantics(super::Semantics::new(rosace_core::Role::Dialog).label(&self.title));
let surface = self.background.unwrap_or_else(|| ctx.tc(ctx.theme.colors.surface));
let r = ctx.rect;
let material = resolve_material::<DialogMaterial>(&ctx.theme, self.material.as_ref());
if self.presentation == DialogPresentation::FullPage {
if let Some(m) = &material {
if let Some(fallback) = m.fallback {
ctx.fill_rect(r, fallback);
}
ctx.shader_fill(r, m.pipeline, m.uniforms.clone());
} else {
ctx.fill_rect(r, surface);
}
} else {
ctx.fill_shadow_rrect(r, self.radius, Color::rgba(0, 0, 0, 100), 16.0);
if let Some(m) = &material {
if let Some(fallback) = m.fallback {
draw_rounded_rect_pub(ctx, r, fallback, self.radius);
}
ctx.shader_fill(r, m.pipeline, m.uniforms.clone());
} else {
draw_rounded_rect_pub(ctx, r, surface, self.radius);
}
}
let inner_rect = EdgeInsets::all(PADDING).shrink(r);
self.build_inner().paint(&mut ctx.child(inner_rect));
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::overlay::{clear_overlays, drain_overlays};
use rosace_layout::Constraints;
#[test]
fn modal_maps_to_centered_block_trap_with_dismissable_scrim() {
let e = Dialog::new("t").overlay_entry(|| {});
assert!(matches!(e.position, LayerPosition::Centered));
assert_eq!(e.input, InputBehavior::Block);
assert_eq!(e.focus, FocusBehavior::Trap);
let scrim = e.scrim.expect("modal must have a barrier scrim");
assert!(scrim.color.a > 0, "modal barrier must be visible");
assert!(scrim.on_tap.is_some(), "modal barrier must dismiss on tap");
}
#[test]
fn non_modal_maps_to_pass_through_with_no_scrim() {
let e = Dialog::new("t").non_modal().overlay_entry(|| {});
assert!(matches!(e.position, LayerPosition::Centered));
assert_eq!(e.input, InputBehavior::PassThrough);
assert_eq!(e.focus, FocusBehavior::PassThrough);
assert!(e.scrim.is_none(), "non-modal must leave the background interactive");
}
#[test]
fn full_page_maps_to_fill_block_trap_with_invisible_escape_scrim() {
let e = Dialog::new("t").full_page().overlay_entry(|| {});
assert!(matches!(e.position, LayerPosition::Fill));
assert_eq!(e.input, InputBehavior::Block);
assert_eq!(e.focus, FocusBehavior::Trap);
let scrim = e.scrim.expect("full-page carries the Escape dismisser");
assert_eq!(scrim.color.a, 0, "full-page barrier must be invisible");
assert!(scrim.on_tap.is_some());
}
#[test]
fn full_page_layout_fills_the_window_modal_keeps_the_card_width() {
let font = rosace_render::FontCache::embedded();
let theme = rosace_theme::built_in::dark_theme();
let ctx = LayoutCtx::new(Constraints::loose(800.0, 600.0), &font, &theme);
let full = Dialog::new("t").full_page().layout(&ctx);
assert_eq!((full.width, full.height), (800.0, 600.0));
let modal = Dialog::new("t").layout(&ctx);
assert_eq!(modal.width, 340.0);
assert!(modal.height < 600.0, "a modal card must not fill the window");
}
#[test]
fn emit_respects_the_open_atom_and_wires_dismiss_to_it() {
clear_overlays();
let open = rosace_state::use_atom(false);
Dialog::new("t").emit(&open);
assert!(drain_overlays().is_empty(), "closed dialog must push nothing");
open.set(true);
Dialog::new("t").emit(&open);
let entries = drain_overlays();
assert_eq!(entries.len(), 1);
let on_tap = entries[0].scrim.as_ref().unwrap().on_tap.as_ref().unwrap().clone();
on_tap();
assert!(!open.get(), "barrier tap must close the dialog");
}
#[test]
fn instance_material_paints_a_shader_fill() {
let font = rosace_render::FontCache::embedded();
let theme = rosace_theme::built_in::dark_theme();
let mut recorder = rosace_render::PictureRecorder::new();
let tree = std::rc::Rc::new(std::cell::RefCell::new(super::super::render_tree::RenderTree::new()));
let rect = rosace_core::types::Rect {
origin: rosace_core::types::Point { x: 0.0, y: 0.0 },
size: Size { width: 340.0, height: 200.0 },
};
let mut ctx = PaintCtx::root(&mut recorder, rect, &font, theme, tree);
let m = ShaderMaterial::new(rosace_shader::PipelineId::user(0x4000), vec![0u8; 16]);
Dialog::new("t").material(m).paint(&mut ctx);
let picture = recorder.finish();
assert!(picture.commands.iter().any(|c| matches!(c, rosace_render::DrawCommand::ShaderFill { .. })));
}
#[test]
fn background_and_color_builders_do_not_change_layout_size() {
let font = rosace_render::FontCache::embedded();
let theme = rosace_theme::built_in::dark_theme();
let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
let base = Dialog::new("Title").message("Body");
let customized = Dialog::new("Title").message("Body")
.background(Color::rgb(10, 10, 10))
.color(Color::rgb(255, 255, 255));
assert_eq!(base.layout(&ctx), customized.layout(&ctx));
}
}