Skip to main content

fui/controls/
dialog.rs

1use super::DialogAppearance;
2use super::{Button, Clickable, Form};
3use crate::app;
4use crate::bindings::ui;
5use crate::ffi::{
6    AlignItems, BorderStyle, FlexDirection, JustifyContent, PositionType, SemanticRole, Unit,
7};
8use crate::node::{
9    flex_box, portal, Border, ChildContainerSurface, FlexBox, Node, NodeRef, TextNode,
10    ThemeBindable, WeakFlexBox,
11};
12use crate::theme::{current_theme, subscribe, Theme};
13use std::cell::RefCell;
14use std::rc::Rc;
15
16const DIALOG_CARD_WIDTH: f32 = 420.0;
17const DIALOG_BACKGROUND_BLUR: f32 = 16.0;
18const DIALOG_SHADOW_OFFSET_X: f32 = 0.0;
19const DIALOG_SHADOW_OFFSET_Y: f32 = 8.0;
20const DIALOG_SHADOW_BLUR: f32 = 10.0;
21const DIALOG_SHADOW_SPREAD: f32 = 0.0;
22
23thread_local! {
24    static ACTIVE_DIALOG: RefCell<Option<DialogEventTarget>> = const { RefCell::new(None) };
25}
26
27type VoidCallback = Rc<dyn Fn()>;
28type ShownCallback = Rc<dyn Fn(DialogShownEventArgs)>;
29
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub struct DialogShownEventArgs;
32
33#[derive(Clone)]
34pub struct Dialog {
35    root: FlexBox,
36    title_node: TextNode,
37    body_node: TextNode,
38    content_host: FlexBox,
39    accept_button: Button,
40    cancel_button: Button,
41    _buttons_row: FlexBox,
42    form: Form,
43    card: FlexBox,
44    overlay: FlexBox,
45    state: Rc<RefCell<DialogState>>,
46}
47
48#[derive(Clone)]
49struct DialogEventTarget {
50    root: WeakFlexBox,
51    overlay: WeakFlexBox,
52    card: WeakFlexBox,
53    form: Form,
54    state: Rc<RefCell<DialogState>>,
55}
56
57struct DialogState {
58    visible: bool,
59    semantic_scope_token: u32,
60    accept_callback: Option<VoidCallback>,
61    cancel_callback: Option<VoidCallback>,
62    shown_callback: Option<ShownCallback>,
63    backdrop_color_value: u32,
64    dialog_background_blur_sigma_value: f32,
65    shadow_color_value: u32,
66    shadow_offset_x_value: f32,
67    shadow_offset_y_value: f32,
68    shadow_blur_sigma_value: f32,
69    shadow_spread_value: f32,
70    card_background_color_value: u32,
71    card_border_width_value: f32,
72    card_border_color_value: u32,
73    card_border_style_value: BorderStyle,
74    card_border_dash_on_value: f32,
75    card_border_dash_off_value: f32,
76    card_corner_top_left_value: f32,
77    card_corner_top_right_value: f32,
78    card_corner_bottom_right_value: f32,
79    card_corner_bottom_left_value: f32,
80    backdrop_color_overridden: bool,
81    shadow_overridden: bool,
82    card_background_overridden: bool,
83    card_border_overridden: bool,
84    card_corner_radius_overridden: bool,
85}
86
87impl DialogState {
88    fn from_theme(theme: Theme) -> Self {
89        Self {
90            visible: false,
91            semantic_scope_token: 0,
92            accept_callback: None,
93            cancel_callback: None,
94            shown_callback: None,
95            backdrop_color_value: theme.colors.dialog_backdrop,
96            dialog_background_blur_sigma_value: DIALOG_BACKGROUND_BLUR,
97            shadow_color_value: theme.colors.dialog_shadow,
98            shadow_offset_x_value: DIALOG_SHADOW_OFFSET_X,
99            shadow_offset_y_value: DIALOG_SHADOW_OFFSET_Y,
100            shadow_blur_sigma_value: DIALOG_SHADOW_BLUR,
101            shadow_spread_value: DIALOG_SHADOW_SPREAD,
102            card_background_color_value: theme.colors.surface,
103            card_border_width_value: 1.0,
104            card_border_color_value: theme.colors.border,
105            card_border_style_value: BorderStyle::Solid,
106            card_border_dash_on_value: 0.0,
107            card_border_dash_off_value: 0.0,
108            card_corner_top_left_value: theme.spacing.md,
109            card_corner_top_right_value: theme.spacing.md,
110            card_corner_bottom_right_value: theme.spacing.md,
111            card_corner_bottom_left_value: theme.spacing.md,
112            backdrop_color_overridden: false,
113            shadow_overridden: false,
114            card_background_overridden: false,
115            card_border_overridden: false,
116            card_corner_radius_overridden: false,
117        }
118    }
119}
120
121impl Default for Dialog {
122    fn default() -> Self {
123        Self::new("", "")
124    }
125}
126
127impl Dialog {
128    pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
129        let theme = current_theme();
130        let root = portal();
131        let title_node = TextNode::new("");
132        title_node
133            .selectable(false)
134            .semantic_role(SemanticRole::Heading);
135        let body_node = TextNode::new("");
136        body_node
137            .selectable(false)
138            .semantic_role(SemanticRole::StaticText);
139        let content_host = flex_box();
140        content_host
141            .width(100.0, Unit::Percent)
142            .flex_direction(FlexDirection::Column);
143        let cancel_button = Button::new("Cancel");
144        let accept_button = Button::new("OK");
145        let buttons_row = flex_box();
146        buttons_row
147            .flex_direction(FlexDirection::Row)
148            .justify_content(JustifyContent::End)
149            .align_items(AlignItems::Center)
150            .width(100.0, Unit::Percent)
151            .child(&accept_button)
152            .child(
153                &flex_box()
154                    .width(12.0, Unit::Pixel)
155                    .height(1.0, Unit::Pixel)
156                    .clone(),
157            )
158            .child(&cancel_button);
159        let form = Form::new();
160        form.default_btn(&accept_button)
161            .cancel_btn(&cancel_button)
162            .child(&buttons_row);
163        let card = flex_box();
164        card.width(DIALOG_CARD_WIDTH, Unit::Pixel)
165            .flex_direction(FlexDirection::Column)
166            .interactive(true)
167            .child(&title_node)
168            .child(
169                &flex_box()
170                    .width(100.0, Unit::Percent)
171                    .height(12.0, Unit::Pixel)
172                    .clone(),
173            )
174            .child(&body_node)
175            .child(
176                &flex_box()
177                    .width(100.0, Unit::Percent)
178                    .height(16.0, Unit::Pixel)
179                    .clone(),
180            )
181            .child(&content_host)
182            .child(
183                &flex_box()
184                    .width(100.0, Unit::Percent)
185                    .height(24.0, Unit::Pixel)
186                    .clone(),
187            )
188            .child(&form)
189            .semantic_role(SemanticRole::Dialog);
190        let overlay = flex_box();
191        overlay
192            .width(100.0, Unit::Percent)
193            .height(100.0, Unit::Percent)
194            .interactive(true)
195            .justify_content(JustifyContent::Center)
196            .align_items(AlignItems::Center)
197            .child(&card);
198        let state = Rc::new(RefCell::new(DialogState::from_theme(theme)));
199
200        root.position_type(PositionType::Absolute)
201            .position(0.0, 0.0)
202            .width(100.0, Unit::Percent)
203            .height(100.0, Unit::Percent);
204
205        let dialog = Self {
206            root,
207            title_node,
208            body_node,
209            content_host,
210            accept_button,
211            cancel_button,
212            _buttons_row: buttons_row,
213            form,
214            card,
215            overlay,
216            state,
217        };
218        dialog.content(title, body);
219        dialog.bind_events();
220        dialog.install_theme_subscription();
221        dialog.apply_theme();
222        dialog
223    }
224
225    pub fn accept_active_dialog() {
226        let dialog = ACTIVE_DIALOG.with(|slot| slot.borrow().as_ref().cloned());
227        if let Some(dialog) = dialog {
228            dialog.accept();
229        }
230    }
231
232    pub fn cancel_active_dialog() {
233        let dialog = ACTIVE_DIALOG.with(|slot| slot.borrow().as_ref().cloned());
234        if let Some(dialog) = dialog {
235            dialog.cancel();
236        }
237    }
238
239    pub fn is_open(&self) -> bool {
240        self.state.borrow().visible
241    }
242
243    pub fn on_accept(&self, handler: impl Fn() + 'static) -> &Self {
244        self.state.borrow_mut().accept_callback = Some(Rc::new(handler));
245        self
246    }
247
248    pub fn on_cancel(&self, handler: impl Fn() + 'static) -> &Self {
249        self.state.borrow_mut().cancel_callback = Some(Rc::new(handler));
250        self
251    }
252
253    pub fn on_shown(&self, handler: impl Fn(DialogShownEventArgs) + 'static) -> &Self {
254        self.state.borrow_mut().shown_callback = Some(Rc::new(handler));
255        self
256    }
257
258    pub fn title_text(&self) -> TextNode {
259        self.title_node.clone()
260    }
261
262    pub fn body_text(&self) -> TextNode {
263        self.body_node.clone()
264    }
265
266    pub fn content_host(&self) -> FlexBox {
267        self.content_host.clone()
268    }
269
270    pub fn accept_action_button(&self) -> Button {
271        self.accept_button.clone()
272    }
273
274    pub fn cancel_action_button(&self) -> Button {
275        self.cancel_button.clone()
276    }
277
278    pub fn appearance(&self, appearance: DialogAppearance) -> &Self {
279        let mut state = self.state.borrow_mut();
280        let backdrop = appearance.backdrop.unwrap_or_default();
281        state.backdrop_color_overridden = backdrop.color.is_some();
282        if let Some(color) = backdrop.color {
283            state.backdrop_color_value = color;
284        }
285        state.dialog_background_blur_sigma_value = backdrop.blur.unwrap_or(DIALOG_BACKGROUND_BLUR);
286
287        let card = appearance.card.unwrap_or_default();
288        state.card_background_overridden = card.background.is_some();
289        if let Some(color) = card.background {
290            state.card_background_color_value = color;
291        }
292        state.card_border_overridden = card.border.is_some();
293        if let Some(border) = card.border {
294            state.card_border_width_value = border.width;
295            state.card_border_color_value = border.color;
296            state.card_border_style_value = border.style;
297            state.card_border_dash_on_value = border.dash_on;
298            state.card_border_dash_off_value = border.dash_off;
299        }
300        state.card_corner_radius_overridden = card.corners.is_some();
301        if let Some(corners) = card.corners {
302            state.card_corner_top_left_value = corners.top_left;
303            state.card_corner_top_right_value = corners.top_right;
304            state.card_corner_bottom_right_value = corners.bottom_right;
305            state.card_corner_bottom_left_value = corners.bottom_left;
306        }
307        state.shadow_overridden = card.shadow.is_some();
308        if let Some(shadow) = card.shadow {
309            state.shadow_color_value = shadow.color;
310            state.shadow_offset_x_value = shadow.offset_x;
311            state.shadow_offset_y_value = shadow.offset_y;
312            state.shadow_blur_sigma_value = shadow.blur_sigma;
313            state.shadow_spread_value = shadow.spread;
314        }
315        drop(state);
316        self.event_target().handle_theme_changed(current_theme());
317        self.apply_theme();
318        self
319    }
320
321    pub fn clear_appearance(&self) -> &Self {
322        self.appearance(DialogAppearance::new())
323    }
324
325    pub fn content(&self, title: impl Into<String>, body: impl Into<String>) -> &Self {
326        let title = title.into();
327        let body = body.into();
328        self.title_node
329            .text(title.clone())
330            .semantic_label(title.clone());
331        self.body_node.text(body.clone()).semantic_label(body);
332        self.card.semantic_label(title);
333        self
334    }
335
336    pub fn show(&self) {
337        self.apply_theme();
338        if self.overlay.parent_handle().is_none() {
339            self.root.child(&self.overlay);
340        }
341        {
342            let mut state = self.state.borrow_mut();
343            if state.semantic_scope_token == 0 && self.overlay.handle().raw() != 0 {
344                state.semantic_scope_token = ui::push_semantic_scope(self.overlay.handle().raw());
345            }
346            state.visible = true;
347        }
348        self.form.activate();
349        if self.accept_button.has_built_handle() {
350            ui::request_focus(self.accept_button.handle().raw());
351        }
352        let event_target = self.event_target();
353        ACTIVE_DIALOG.with(|slot| slot.borrow_mut().replace(event_target.clone()));
354        app::after_next_commit(move || {
355            event_target.fire_shown_after_commit();
356        });
357    }
358
359    pub fn hide(&self) {
360        self.event_target().hide();
361    }
362
363    fn bind_events(&self) {
364        self.card.on_pointer_down(|event| {
365            event.handled = true;
366        });
367
368        let event_target = self.event_target();
369        self.overlay.on_pointer_click(move |event| {
370            event_target.handle_backdrop_click(event);
371        });
372
373        self.accept_button.on_click(|_event| {
374            Dialog::accept_active_dialog();
375        });
376
377        self.cancel_button.on_click(|_event| {
378            Dialog::cancel_active_dialog();
379        });
380    }
381
382    fn install_theme_subscription(&self) {
383        let event_target = self.event_target();
384        let title = self.title_node.clone();
385        let body = self.body_node.clone();
386        let guard = subscribe(move |theme| {
387            event_target.handle_theme_changed(theme);
388            event_target.apply_theme(&title, &body);
389        });
390        self.root
391            .retained_node_ref()
392            .retain_attachment(Rc::new(guard));
393    }
394
395    fn apply_theme(&self) {
396        self.event_target()
397            .apply_theme(&self.title_node, &self.body_node);
398    }
399
400    fn event_target(&self) -> DialogEventTarget {
401        DialogEventTarget {
402            root: self.root.downgrade(),
403            overlay: self.overlay.downgrade(),
404            card: self.card.downgrade(),
405            form: self.form.clone(),
406            state: self.state.clone(),
407        }
408    }
409}
410
411impl Node for Dialog {
412    fn retained_node_ref(&self) -> NodeRef {
413        self.root.retained_node_ref()
414    }
415
416    fn retained_owner_attachment(&self) -> Option<Rc<dyn std::any::Any>> {
417        Some(Rc::new(self.clone()))
418    }
419
420    fn build_self(&self) {
421        self.apply_theme();
422        self.root.build_self();
423    }
424
425    fn dispose(&self) {
426        self.hide();
427        if self.overlay.handle().raw() != 0 {
428            self.overlay.dispose();
429        }
430        self.root.dispose();
431    }
432}
433
434impl crate::node::HasFlexBoxRoot for Dialog {
435    fn flex_box_root(&self) -> &FlexBox {
436        &self.root
437    }
438}
439
440impl ThemeBindable for Dialog {
441    fn theme_binding_node(&self) -> NodeRef {
442        self.root.retained_node_ref()
443    }
444
445    fn weak_theme_target(&self) -> Box<dyn Fn() -> Option<Self>> {
446        let root = self.root.downgrade();
447        let title_node = self.title_node.clone();
448        let body_node = self.body_node.clone();
449        let content_host = self.content_host.clone();
450        let accept_button = self.accept_button.clone();
451        let cancel_button = self.cancel_button.clone();
452        let buttons_row = self._buttons_row.clone();
453        let form = self.form.clone();
454        let card = self.card.clone();
455        let overlay = self.overlay.clone();
456        let state = self.state.clone();
457        Box::new(move || {
458            Some(Self {
459                root: root.upgrade()?,
460                title_node: title_node.clone(),
461                body_node: body_node.clone(),
462                content_host: content_host.clone(),
463                accept_button: accept_button.clone(),
464                cancel_button: cancel_button.clone(),
465                _buttons_row: buttons_row.clone(),
466                form: form.clone(),
467                card: card.clone(),
468                overlay: overlay.clone(),
469                state: state.clone(),
470            })
471        })
472    }
473}
474
475impl DialogEventTarget {
476    fn hide(&self) {
477        let Some(root) = self.root.upgrade() else {
478            return;
479        };
480        let Some(overlay) = self.overlay.upgrade() else {
481            return;
482        };
483        let mut state = self.state.borrow_mut();
484        if !state.visible && overlay.parent_handle().is_none() {
485            return;
486        }
487        if state.semantic_scope_token != 0 {
488            ui::remove_semantic_scope(state.semantic_scope_token);
489            state.semantic_scope_token = 0;
490        }
491        state.visible = false;
492        drop(state);
493        self.form.deactivate();
494        root.remove_child(&overlay);
495        ACTIVE_DIALOG.with(|slot| {
496            let should_clear = {
497                let borrowed = slot.borrow();
498                borrowed
499                    .as_ref()
500                    .is_some_and(|active| Rc::ptr_eq(&active.state, &self.state))
501            };
502            if should_clear {
503                slot.borrow_mut().take();
504            }
505        });
506    }
507
508    fn accept(&self) {
509        let callback = self.state.borrow().accept_callback.clone();
510        self.hide();
511        if let Some(callback) = callback {
512            callback();
513        }
514    }
515
516    fn cancel(&self) {
517        let callback = self.state.borrow().cancel_callback.clone();
518        self.hide();
519        if let Some(callback) = callback {
520            callback();
521        }
522    }
523
524    fn fire_shown_after_commit(&self) {
525        let state = self.state.borrow();
526        if !state.visible {
527            return;
528        }
529        let Some(overlay) = self.overlay.upgrade() else {
530            return;
531        };
532        if overlay.parent_handle().is_none() {
533            return;
534        }
535        let callback = state.shown_callback.clone();
536        drop(state);
537        if let Some(callback) = callback {
538            callback(DialogShownEventArgs);
539        }
540    }
541
542    fn handle_theme_changed(&self, theme: Theme) {
543        let mut state = self.state.borrow_mut();
544        if !state.backdrop_color_overridden {
545            state.backdrop_color_value = theme.colors.dialog_backdrop;
546        }
547        if !state.shadow_overridden {
548            state.shadow_color_value = theme.colors.dialog_shadow;
549        }
550        if !state.card_background_overridden {
551            state.card_background_color_value = theme.colors.surface;
552        }
553        if !state.card_border_overridden {
554            state.card_border_width_value = 1.0;
555            state.card_border_color_value = theme.colors.border;
556            state.card_border_style_value = BorderStyle::Solid;
557            state.card_border_dash_on_value = 0.0;
558            state.card_border_dash_off_value = 0.0;
559        }
560        if !state.card_corner_radius_overridden {
561            state.card_corner_top_left_value = theme.spacing.md;
562            state.card_corner_top_right_value = theme.spacing.md;
563            state.card_corner_bottom_right_value = theme.spacing.md;
564            state.card_corner_bottom_left_value = theme.spacing.md;
565        }
566    }
567
568    fn apply_theme(&self, title: &TextNode, body: &TextNode) {
569        let theme = current_theme();
570        let state = self.state.borrow();
571        if let Some(overlay) = self.overlay.upgrade() {
572            overlay
573                .bg_color(state.backdrop_color_value)
574                .background_blur(state.dialog_background_blur_sigma_value);
575        }
576        if let Some(card) = self.card.upgrade() {
577            card.bg_color(state.card_background_color_value)
578                .corners(
579                    state.card_corner_top_left_value,
580                    state.card_corner_top_right_value,
581                    state.card_corner_bottom_right_value,
582                    state.card_corner_bottom_left_value,
583                )
584                .border_config(Border {
585                    width: state.card_border_width_value,
586                    color: state.card_border_color_value,
587                    style: state.card_border_style_value,
588                    dash_on: state.card_border_dash_on_value,
589                    dash_off: state.card_border_dash_off_value,
590                })
591                .drop_shadow(
592                    state.shadow_color_value,
593                    state.shadow_offset_x_value,
594                    state.shadow_offset_y_value,
595                    state.shadow_blur_sigma_value,
596                    state.shadow_spread_value,
597                )
598                .padding(24.0, 24.0, 24.0, 24.0);
599        }
600        drop(state);
601        title
602            .font_family(theme.fonts.heading_family.clone())
603            .font_size(theme.fonts.size_heading)
604            .text_color(theme.colors.text_primary);
605        body.font_family(theme.fonts.body_family.clone())
606            .font_size(theme.fonts.size_body)
607            .text_color(theme.colors.text_muted);
608    }
609
610    fn handle_backdrop_click(&self, event: &mut crate::PointerEventArgs) {
611        let Some(card) = self.card.upgrade() else {
612            return;
613        };
614        if let Some(bounds) = ui::get_bounds(card.handle().raw()) {
615            let left = bounds[0];
616            let top = bounds[1];
617            let right = left + bounds[2];
618            let bottom = top + bounds[3];
619            if event.scene_x >= left
620                && event.scene_x <= right
621                && event.scene_y >= top
622                && event.scene_y <= bottom
623            {
624                return;
625            }
626        }
627        event.handled = true;
628        self.cancel();
629    }
630}