1use std::sync::Arc;
2
3use rosace_core::types::Size;
4use rosace_layout::Constraints;
5use rosace_render::Color;
6use rosace_shader::ShaderMaterial;
7use rosace_state::Atom;
8use super::{Widget, LayoutCtx, PaintCtx, BoxedWidget};
9use super::button::{Button, ButtonVariant};
10use super::column::Column;
11use super::container::draw_rounded_rect_pub;
12use super::material::{resolve_material, DialogMaterial};
13use super::overlay::{
14 FocusBehavior, InputBehavior, LayerPosition, OverlayEntry, ScrimConfig, push_overlay,
15};
16use super::padding::EdgeInsets;
17use super::row::Row;
18use super::text::Text;
19use rosace_layout::MainAxisAlignment;
20
21type Action = (String, ButtonVariant, Arc<dyn Fn() + Send + Sync>);
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
25pub enum DialogPresentation {
26 #[default]
30 Modal,
31 NonModal,
36 FullPage,
41}
42
43pub struct Dialog {
65 pub title: String,
66 pub message: Option<String>,
67 pub width: f32,
68 pub radius: f32,
69 pub presentation: DialogPresentation,
70 background: Option<Color>,
71 color: Option<Color>,
72 material: Option<ShaderMaterial>,
73 actions: Vec<Action>,
74}
75
76impl Dialog {
77 pub fn new(title: impl Into<String>) -> Self {
78 Self {
79 title: title.into(),
80 message: None,
81 width: 340.0,
82 radius: 12.0,
83 presentation: DialogPresentation::default(),
84 background: None,
85 color: None,
86 material: None,
87 actions: Vec::new(),
88 }
89 }
90
91 pub fn message(mut self, m: impl Into<String>) -> Self { self.message = Some(m.into()); self }
92 pub fn width(mut self, w: f32) -> Self { self.width = w; self }
93 pub fn radius(mut self, r: f32) -> Self { self.radius = r; self }
94 pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
96 pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
98 pub fn material(mut self, m: ShaderMaterial) -> Self { self.material = Some(m); self }
101
102 pub fn modal(mut self) -> Self { self.presentation = DialogPresentation::Modal; self }
105
106 pub fn non_modal(mut self) -> Self { self.presentation = DialogPresentation::NonModal; self }
109
110 pub fn full_page(mut self) -> Self { self.presentation = DialogPresentation::FullPage; self }
113
114 pub fn action(mut self, label: impl Into<String>, f: impl Fn() + Send + Sync + 'static) -> Self {
116 self.actions.push((label.into(), ButtonVariant::Secondary, Arc::new(f)));
117 self
118 }
119
120 pub fn primary_action(mut self, label: impl Into<String>, f: impl Fn() + Send + Sync + 'static) -> Self {
122 self.actions.push((label.into(), ButtonVariant::Primary, Arc::new(f)));
123 self
124 }
125
126 pub fn destructive_action(mut self, label: impl Into<String>, f: impl Fn() + Send + Sync + 'static) -> Self {
128 self.actions.push((label.into(), ButtonVariant::Danger, Arc::new(f)));
129 self
130 }
131
132 pub fn overlay_entry(self, on_dismiss: impl Fn() + Send + Sync + 'static) -> OverlayEntry {
138 match self.presentation {
139 DialogPresentation::Modal => {
140 OverlayEntry::new(LayerPosition::Centered, self)
141 .input(InputBehavior::Block)
142 .focus(FocusBehavior::Trap)
143 .scrim(ScrimConfig {
144 color: Color::rgba(0, 0, 0, 160),
145 on_tap: Some(Arc::new(on_dismiss)),
146 exclude_rect: None,
147 })
148 }
149 DialogPresentation::NonModal => {
150 OverlayEntry::new(LayerPosition::Centered, self)
151 .input(InputBehavior::PassThrough)
152 .focus(FocusBehavior::PassThrough)
153 }
154 DialogPresentation::FullPage => {
155 OverlayEntry::new(LayerPosition::Fill, self)
160 .input(InputBehavior::Block)
161 .focus(FocusBehavior::Trap)
162 .scrim(ScrimConfig {
163 color: Color::TRANSPARENT,
164 on_tap: Some(Arc::new(on_dismiss)),
165 exclude_rect: None,
166 })
167 }
168 }
169 }
170
171 pub fn emit(self, open: &Atom<bool>) {
179 if !open.get() { return; }
180 let close = open.clone();
181 push_overlay(self.overlay_entry(move || close.set(false)));
182 }
183
184 fn build_inner(&self) -> BoxedWidget {
189 let mut title = Text::title(&self.title);
190 if let Some(c) = self.color { title = title.color(c); }
191 let mut col = Column::new()
192 .spacing(12.0)
193 .child(title);
194
195 if let Some(msg) = &self.message {
196 let mut msg_text = Text::caption(msg);
197 if let Some(c) = self.color { msg_text = msg_text.color(c); }
198 col = col.child(msg_text);
199 }
200
201 if !self.actions.is_empty() {
202 let mut actions = Row::new()
203 .spacing(8.0)
204 .main_axis_alignment(MainAxisAlignment::End);
205 for (label, variant, cb) in &self.actions {
206 let cb = Arc::clone(cb);
207 actions = actions.child(
208 Button::new(label.clone())
209 .variant(*variant)
210 .on_press(move || cb()),
211 );
212 }
213 col = col.child(actions);
214 }
215
216 Box::new(col)
217 }
218}
219
220const PADDING: f32 = 20.0;
221
222impl Widget for Dialog {
223 fn layout(&self, ctx: &LayoutCtx) -> Size {
224 if self.presentation == DialogPresentation::FullPage {
225 return ctx.constraints.constrain(Size {
228 width: super::avail_w(ctx.constraints),
229 height: super::avail_h(ctx.constraints),
230 });
231 }
232 let inner = self.build_inner();
233 let inner_c = Constraints::loose(self.width - PADDING * 2.0, f32::INFINITY);
234 let inner_size = inner.layout(&ctx.with_constraints(inner_c));
235 ctx.constraints.constrain(Size {
236 width: self.width,
237 height: inner_size.height + PADDING * 2.0,
238 })
239 }
240
241 fn paint(&self, ctx: &mut PaintCtx) {
242 ctx.semantics(super::Semantics::new(rosace_core::Role::Dialog).label(&self.title));
243 let surface = self.background.unwrap_or_else(|| ctx.tc(ctx.theme.colors.surface));
244 let r = ctx.rect;
245 let material = resolve_material::<DialogMaterial>(&ctx.theme, self.material.as_ref());
246 if self.presentation == DialogPresentation::FullPage {
252 if let Some(m) = &material {
254 if let Some(fallback) = m.fallback {
255 ctx.fill_rect(r, fallback);
256 }
257 ctx.shader_fill(r, m.pipeline, m.uniforms.clone());
258 } else {
259 ctx.fill_rect(r, surface);
260 }
261 } else {
262 ctx.fill_shadow_rrect(r, self.radius, Color::rgba(0, 0, 0, 100), 16.0);
263 if let Some(m) = &material {
264 if let Some(fallback) = m.fallback {
265 draw_rounded_rect_pub(ctx, r, fallback, self.radius);
266 }
267 ctx.shader_fill(r, m.pipeline, m.uniforms.clone());
268 } else {
269 draw_rounded_rect_pub(ctx, r, surface, self.radius);
270 }
271 }
272
273 let inner_rect = EdgeInsets::all(PADDING).shrink(r);
274 self.build_inner().paint(&mut ctx.child(inner_rect));
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use super::super::overlay::{clear_overlays, drain_overlays};
282 use rosace_layout::Constraints;
283
284 #[test]
285 fn modal_maps_to_centered_block_trap_with_dismissable_scrim() {
286 let e = Dialog::new("t").overlay_entry(|| {});
287 assert!(matches!(e.position, LayerPosition::Centered));
288 assert_eq!(e.input, InputBehavior::Block);
289 assert_eq!(e.focus, FocusBehavior::Trap);
290 let scrim = e.scrim.expect("modal must have a barrier scrim");
291 assert!(scrim.color.a > 0, "modal barrier must be visible");
292 assert!(scrim.on_tap.is_some(), "modal barrier must dismiss on tap");
293 }
294
295 #[test]
296 fn non_modal_maps_to_pass_through_with_no_scrim() {
297 let e = Dialog::new("t").non_modal().overlay_entry(|| {});
298 assert!(matches!(e.position, LayerPosition::Centered));
299 assert_eq!(e.input, InputBehavior::PassThrough);
300 assert_eq!(e.focus, FocusBehavior::PassThrough);
301 assert!(e.scrim.is_none(), "non-modal must leave the background interactive");
302 }
303
304 #[test]
305 fn full_page_maps_to_fill_block_trap_with_invisible_escape_scrim() {
306 let e = Dialog::new("t").full_page().overlay_entry(|| {});
307 assert!(matches!(e.position, LayerPosition::Fill));
308 assert_eq!(e.input, InputBehavior::Block);
309 assert_eq!(e.focus, FocusBehavior::Trap);
310 let scrim = e.scrim.expect("full-page carries the Escape dismisser");
311 assert_eq!(scrim.color.a, 0, "full-page barrier must be invisible");
312 assert!(scrim.on_tap.is_some());
313 }
314
315 #[test]
316 fn full_page_layout_fills_the_window_modal_keeps_the_card_width() {
317 let font = rosace_render::FontCache::embedded();
318 let theme = rosace_theme::built_in::dark_theme();
319 let ctx = LayoutCtx::new(Constraints::loose(800.0, 600.0), &font, &theme);
320
321 let full = Dialog::new("t").full_page().layout(&ctx);
322 assert_eq!((full.width, full.height), (800.0, 600.0));
323
324 let modal = Dialog::new("t").layout(&ctx);
325 assert_eq!(modal.width, 340.0);
326 assert!(modal.height < 600.0, "a modal card must not fill the window");
327 }
328
329 #[test]
330 fn emit_respects_the_open_atom_and_wires_dismiss_to_it() {
331 clear_overlays();
332 let open = rosace_state::use_atom(false);
333 Dialog::new("t").emit(&open);
334 assert!(drain_overlays().is_empty(), "closed dialog must push nothing");
335
336 open.set(true);
337 Dialog::new("t").emit(&open);
338 let entries = drain_overlays();
339 assert_eq!(entries.len(), 1);
340 let on_tap = entries[0].scrim.as_ref().unwrap().on_tap.as_ref().unwrap().clone();
341 on_tap();
342 assert!(!open.get(), "barrier tap must close the dialog");
343 }
344
345 #[test]
346 fn instance_material_paints_a_shader_fill() {
347 let font = rosace_render::FontCache::embedded();
348 let theme = rosace_theme::built_in::dark_theme();
349 let mut recorder = rosace_render::PictureRecorder::new();
350 let tree = std::rc::Rc::new(std::cell::RefCell::new(super::super::render_tree::RenderTree::new()));
351 let rect = rosace_core::types::Rect {
352 origin: rosace_core::types::Point { x: 0.0, y: 0.0 },
353 size: Size { width: 340.0, height: 200.0 },
354 };
355 let mut ctx = PaintCtx::root(&mut recorder, rect, &font, theme, tree);
356 let m = ShaderMaterial::new(rosace_shader::PipelineId::user(0x4000), vec![0u8; 16]);
357 Dialog::new("t").material(m).paint(&mut ctx);
358 let picture = recorder.finish();
359 assert!(picture.commands.iter().any(|c| matches!(c, rosace_render::DrawCommand::ShaderFill { .. })));
360 }
361
362 #[test]
363 fn background_and_color_builders_do_not_change_layout_size() {
364 let font = rosace_render::FontCache::embedded();
365 let theme = rosace_theme::built_in::dark_theme();
366 let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
367 let base = Dialog::new("Title").message("Body");
368 let customized = Dialog::new("Title").message("Body")
369 .background(Color::rgb(10, 10, 10))
370 .color(Color::rgb(255, 255, 255));
371 assert_eq!(base.layout(&ctx), customized.layout(&ctx));
372 }
373}