Skip to main content

rosace_widgets/tree/
dialog.rs

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/// How a [`Dialog`] presents when emitted as an overlay (D115/Phase 32 Step 1).
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
25pub enum DialogPresentation {
26    /// Centered card over a dimmed barrier. Input to the content below is
27    /// blocked, Tab focus is trapped inside, and a tap on the barrier (or
28    /// Escape) dismisses. The default.
29    #[default]
30    Modal,
31    /// Centered card with NO barrier — the content below stays fully
32    /// interactive (inspector-panel / tool-palette style). Clicks on the
33    /// card itself are absorbed; everything else falls through. Dismissal
34    /// is the dialog's own responsibility (an action button).
35    NonModal,
36    /// Fills the entire window, like a pushed page — the Material
37    /// full-screen dialog. Input below is blocked and focus is trapped;
38    /// Escape still dismisses (via an invisible barrier), but there is no
39    /// outside area to tap.
40    FullPage,
41}
42
43/// A dialog surface: title, optional message, action buttons.
44///
45/// Two ways to present it:
46/// - [`OverlayApi::dialog`] — co-located declaration; always the modal
47///   presentation (scrim, centering, input blocking, focus trap).
48/// - [`Dialog::emit`] — the [`Drawer::emit`]-style per-frame push, which
49///   honors the presentation chosen with [`Dialog::modal`] /
50///   [`Dialog::non_modal`] / [`Dialog::full_page`].
51///
52/// ```rust,ignore
53/// Button::new("Delete")
54///     .dialog(confirm.clone(), move || Box::new(
55///         Dialog::new("Delete item?")
56///             .message("This cannot be undone.")
57///             .action("Cancel", { let c = confirm.clone(); move || c.set(false) })
58///             .destructive_action("Delete", move || { /* … */ })
59///     ))
60/// ```
61///
62/// [`OverlayApi::dialog`]: super::overlay_api::OverlayApi::dialog
63/// [`Drawer::emit`]: super::drawer::Drawer::emit
64pub 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    /// Dialog surface fill color (theme's `surface` if unset).
95    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
96    /// Title/message text color (theme's `on_surface` if unset).
97    pub fn color(mut self, c: Color) -> Self { self.color = Some(c); self }
98    /// Per-instance shader material — replaces the surface fill when
99    /// resolved. Beats the theme's `DialogMaterial` default (D124 Step 5).
100    pub fn material(mut self, m: ShaderMaterial) -> Self { self.material = Some(m); self }
101
102    /// Present as a modal dialog (the default) — see
103    /// [`DialogPresentation::Modal`].
104    pub fn modal(mut self) -> Self { self.presentation = DialogPresentation::Modal; self }
105
106    /// Present as a non-modal dialog — the content below stays interactive.
107    /// See [`DialogPresentation::NonModal`].
108    pub fn non_modal(mut self) -> Self { self.presentation = DialogPresentation::NonModal; self }
109
110    /// Present full-page — the dialog fills the window like a pushed page.
111    /// See [`DialogPresentation::FullPage`].
112    pub fn full_page(mut self) -> Self { self.presentation = DialogPresentation::FullPage; self }
113
114    /// Add a neutral (secondary) action button.
115    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    /// Add a highlighted (primary) action button.
121    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    /// Add a destructive (danger) action button.
127    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    /// The pure presentation→overlay-config mapping: consumes the dialog and
133    /// returns the [`OverlayEntry`] that presents it. `on_dismiss` is wired
134    /// to the barrier (scrim tap / Escape) where the presentation has one;
135    /// [`DialogPresentation::NonModal`] has no barrier, so `on_dismiss` is
136    /// simply unused there.
137    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                // The transparent scrim draws nothing visible and can never
156                // be tapped (the page covers the window), but it carries the
157                // dismisser so Escape still closes the page — same dismissal
158                // key the modal presentation honors.
159                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    /// Present via the overlay stack while `open` is true — same per-frame
172    /// re-push convention as [`Drawer::emit`] / [`Snackbar::emit`]: call from
173    /// a host widget's paint (or the app's build) every frame the dialog
174    /// should be visible. The barrier dismisser sets `open` to false.
175    ///
176    /// [`Drawer::emit`]: super::drawer::Drawer::emit
177    /// [`Snackbar::emit`]: super::snackbar::Snackbar::emit
178    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    /// Compose the inner content tree from the stored parts.
185    ///
186    /// Rebuilt on each layout/paint call — construction is a few allocations,
187    /// far below the cost of the paint itself.
188    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            // A full-page dialog fills whatever it is given (the overlay
226            // pass hands it the window).
227            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        // With a material, only paint a fallback it EXPLICITLY carries —
247        // an unconditional base fill lands in the scene right before the
248        // shader quad, so a backdrop-sampling glass material would sample
249        // the fill instead of the content behind the dialog (same rule as
250        // Container/Card).
251        if self.presentation == DialogPresentation::FullPage {
252            // A page, not a floating card: square, edge-to-edge, no shadow.
253            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}