Skip to main content

sequoia_elements/
modal.rs

1#![forbid(unsafe_code)]
2
3use std::cell::RefCell;
4use std::collections::HashMap;
5use std::rc::Rc;
6use std::time::Instant;
7
8use sequoia_core::{Color, Edges, Length};
9use sequoia_layout::LayoutStyle;
10use sequoia_reactive::Signal;
11use sequoia_style::{Backdrop, BoxStyle, ModalStyle, ShadowSpec, Theme};
12use sequoia_view::{
13    Anchor, AnyView, Dismiss, Easing, InputMode, OverlayEntry, OverlayId, OverlayLayer,
14    OverlayOverflow, PaintCtx, Placement, Presence, Unit, View,
15};
16
17thread_local! {
18    static ENTRIES: RefCell<HashMap<usize, OverlayId>> = RefCell::new(HashMap::new());
19}
20
21fn get_entry(key: usize) -> Option<OverlayId> {
22    ENTRIES.with(|m| m.borrow().get(&key).copied())
23}
24
25fn insert_entry(key: usize, id: OverlayId) {
26    ENTRIES.with(|m| {
27        m.borrow_mut().insert(key, id);
28    });
29}
30
31fn take_entry(key: usize) -> Option<OverlayId> {
32    ENTRIES.with(|m| m.borrow_mut().remove(&key))
33}
34
35thread_local! {
36    static SCRIM_CLOCKS: RefCell<HashMap<usize, Rc<RefCell<Option<Instant>>>>> =
37        RefCell::new(HashMap::new());
38}
39
40fn scrim_clock(open_id: usize) -> Rc<RefCell<Option<Instant>>> {
41    SCRIM_CLOCKS.with(|m| m.borrow_mut().entry(open_id).or_default().clone())
42}
43
44fn forget_scrim_clock(open_id: usize) {
45    SCRIM_CLOCKS.with(|m| {
46        m.borrow_mut().remove(&open_id);
47    });
48}
49
50pub const DEFAULT_SCRIM: Color = Color::rgba(0.0, 0.0, 0.0, 0.45);
51
52pub(crate) const MODAL_ENTER_SECS: f32 = 0.22;
53const MODAL_ENTER_EASE: Easing = Easing::CubicBezier(0.3, 1.3, 0.4, 1.0);
54const MODAL_ENTER_DY: f32 = -8.0;
55
56pub(crate) const SCRIM_FADE_SECS: f32 = 0.18;
57
58struct ScrimFill {
59    scrim: Color,
60    backdrop: Backdrop,
61    open_id: usize,
62    fade: Rc<RefCell<Option<Instant>>>,
63}
64
65fn fade_in_opacity(clock: &Rc<RefCell<Option<Instant>>>, secs: f32) -> (f32, bool) {
66    let now = Instant::now();
67    let start = *clock.borrow_mut().get_or_insert(now);
68    let el = now.duration_since(start).as_secs_f32();
69    if el >= secs {
70        (1.0, false)
71    } else {
72        (el / secs.max(f32::MIN_POSITIVE), true)
73    }
74}
75
76impl View for ScrimFill {
77    fn body(&self) -> impl View + 'static {
78        Unit
79    }
80    fn is_leaf(&self) -> bool {
81        true
82    }
83    fn layout_style(&self) -> LayoutStyle {
84        LayoutStyle {
85            width: Length::Fill(1.0),
86            height: Length::Fill(1.0),
87            ..LayoutStyle::default()
88        }
89    }
90    fn hit_testable(&self) -> bool {
91        false
92    }
93    fn on_unmount(&self) {
94        forget_scrim_clock(self.open_id);
95    }
96    fn paint_opacity(&self) -> Option<f32> {
97        Some(fade_in_opacity(&self.fade, SCRIM_FADE_SECS).0)
98    }
99    fn paint_self(&self, ctx: &mut PaintCtx) {
100        ctx.push_backdrop(ctx.rect, self.backdrop);
101        if self.scrim.a > 0.0 {
102            ctx.quad(ctx.rect, self.scrim, 0.0);
103        }
104        if fade_in_opacity(&self.fade, SCRIM_FADE_SECS).1 {
105            ctx.request_frame();
106        }
107    }
108}
109
110fn default_shadow() -> ShadowSpec {
111    ShadowSpec::new(Color::BLACK.with_alpha(0.35), 24.0).offset(0.0, 8.0)
112}
113
114pub(crate) struct OverlayPanel {
115    pub child: AnyView,
116    pub width: Length,
117    pub height: Length,
118    pub padding: Edges,
119    pub background: Color,
120    pub border: Color,
121    pub border_width: f32,
122    pub radius: f32,
123    pub backdrop: Backdrop,
124}
125
126impl View for OverlayPanel {
127    fn body(&self) -> impl View + 'static {
128        Unit
129    }
130    fn children_override(&self) -> Option<Vec<AnyView>> {
131        Some(vec![self.child.clone()])
132    }
133    fn layout_style(&self) -> LayoutStyle {
134        LayoutStyle {
135            width: self.width,
136            height: self.height,
137            padding: self.padding,
138            ..LayoutStyle::default()
139        }
140    }
141    fn paint_self(&self, ctx: &mut PaintCtx) {
142        let radius = self.radius.max(0.0);
143        ctx.push_backdrop(
144            ctx.rect,
145            Backdrop {
146                radius,
147                ..self.backdrop
148            },
149        );
150        ctx.paint_box(
151            BoxStyle::new()
152                .background(self.background)
153                .border(self.border_width.max(0.0), self.border)
154                .radius(radius)
155                .shadow(default_shadow()),
156        );
157    }
158}
159
160#[derive(Clone)]
161pub struct ModalScrim {
162    open: Signal<bool>,
163    scrim: Color,
164    backdrop: Backdrop,
165}
166
167impl ModalScrim {
168    pub fn new(open: Signal<bool>) -> Self {
169        Self {
170            open,
171            scrim: DEFAULT_SCRIM,
172            backdrop: Backdrop::NONE,
173        }
174    }
175
176    pub fn scrim_color(mut self, color: Color) -> Self {
177        self.scrim = color;
178        self
179    }
180
181    pub fn backdrop(mut self, backdrop: Backdrop) -> Self {
182        self.backdrop = backdrop;
183        self
184    }
185
186    fn is_open(&self) -> bool {
187        self.open.get_untracked()
188    }
189}
190
191impl View for ModalScrim {
192    fn body(&self) -> impl View + 'static {
193        Unit
194    }
195    fn keyed_children(&self) -> Option<Vec<(u64, AnyView)>> {
196        let open = self.open.get();
197        if !open {
198            return Some(Vec::new());
199        }
200        Some(vec![(
201            0,
202            AnyView::new(ScrimFill {
203                scrim: self.scrim,
204                backdrop: Backdrop {
205                    radius: 0.0,
206                    ..self.backdrop
207                },
208                open_id: self.open.id(),
209
210                fade: scrim_clock(self.open.id()),
211            }),
212        )])
213    }
214    fn layout_style(&self) -> LayoutStyle {
215        if self.is_open() {
216            LayoutStyle {
217                width: Length::Fill(1.0),
218                height: Length::Fill(1.0),
219                ..LayoutStyle::default()
220            }
221        } else {
222            LayoutStyle {
223                width: Length::Fit,
224                height: Length::Fit,
225                ..LayoutStyle::default()
226            }
227        }
228    }
229    fn hit_testable(&self) -> bool {
230        false
231    }
232}
233
234pub struct Modal {
235    open: Signal<bool>,
236    child: Option<AnyView>,
237    width: Length,
238    height: Length,
239    padding: Edges,
240    style: Option<ModalStyle>,
241    background: Option<Color>,
242    border: Option<Color>,
243    border_width: Option<f32>,
244    radius: Option<f32>,
245    backdrop: Option<Backdrop>,
246    scrim: Option<Color>,
247    scrim_backdrop: Option<Backdrop>,
248    dismiss: Dismiss,
249    margin: Edges,
250}
251
252impl Modal {
253    pub fn new(open: Signal<bool>) -> Self {
254        Self {
255            open,
256            child: None,
257            width: Length::Fit,
258            height: Length::Fit,
259            padding: Edges::ZERO,
260            style: None,
261            background: None,
262            border: None,
263            border_width: None,
264            radius: None,
265            backdrop: None,
266            scrim: None,
267            scrim_backdrop: None,
268            dismiss: Dismiss::OutsideClick,
269            margin: Edges::ZERO,
270        }
271    }
272
273    pub fn child<V: View + 'static>(mut self, child: V) -> Self {
274        self.child = Some(AnyView::new(child));
275        self
276    }
277
278    pub fn child_if<V: View + 'static>(mut self, cond: bool, make: impl FnOnce() -> V) -> Self {
279        if cond {
280            self.child = Some(AnyView::new(make()));
281        }
282        self
283    }
284
285    pub fn child_opt<V: View + 'static>(mut self, child: Option<V>) -> Self {
286        if let Some(v) = child {
287            self.child = Some(AnyView::new(v));
288        }
289        self
290    }
291
292    pub fn width(mut self, width: impl Into<Length>) -> Self {
293        self.width = width.into();
294        self
295    }
296
297    pub fn height(mut self, height: impl Into<Length>) -> Self {
298        self.height = height.into();
299        self
300    }
301
302    pub fn size(mut self, width: impl Into<Length>, height: impl Into<Length>) -> Self {
303        self.width = width.into();
304        self.height = height.into();
305        self
306    }
307
308    pub fn padding(mut self, padding: impl Into<Edges>) -> Self {
309        self.padding = padding.into();
310        self
311    }
312
313    pub fn pad(self, padding: impl Into<Edges>) -> Self {
314        self.padding(padding)
315    }
316
317    pub fn padding_xy(mut self, x: f32, y: f32) -> Self {
318        self.padding = Edges::xy(x, y);
319        self
320    }
321
322    pub fn style(mut self, style: ModalStyle) -> Self {
323        self.style = Some(style);
324        self
325    }
326
327    pub fn background(mut self, color: Color) -> Self {
328        self.background = Some(color);
329        self
330    }
331
332    pub fn border_color(mut self, color: Color) -> Self {
333        self.border = Some(color);
334        self
335    }
336
337    pub fn border_width(mut self, width: f32) -> Self {
338        self.border_width = Some(width.max(0.0));
339        self
340    }
341
342    pub fn border(mut self, width: f32, color: Color) -> Self {
343        self.border_width = Some(width.max(0.0));
344        self.border = Some(color);
345        self
346    }
347
348    pub fn radius(mut self, radius: f32) -> Self {
349        self.radius = Some(radius.max(0.0));
350        self
351    }
352
353    pub fn backdrop(mut self, backdrop: Backdrop) -> Self {
354        self.backdrop = Some(backdrop);
355        self
356    }
357
358    pub fn scrim_color(mut self, color: Color) -> Self {
359        self.scrim = Some(color);
360        self
361    }
362
363    pub fn scrim_backdrop(mut self, backdrop: Backdrop) -> Self {
364        self.scrim_backdrop = Some(backdrop);
365        self
366    }
367
368    pub fn dismiss(mut self, dismiss: Dismiss) -> Self {
369        self.dismiss = dismiss;
370        self
371    }
372
373    pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
374        let m = margin.into();
375        self.margin = Edges::only(
376            sanitize_margin(m.left),
377            sanitize_margin(m.top),
378            sanitize_margin(m.right),
379            sanitize_margin(m.bottom),
380        );
381        self
382    }
383
384    pub fn margin_xy(self, x: f32, y: f32) -> Self {
385        self.margin(Edges::xy(x, y))
386    }
387
388    pub fn margin_x(mut self, x: f32) -> Self {
389        let x = sanitize_margin(x);
390        self.margin.left = x;
391        self.margin.right = x;
392        self
393    }
394
395    pub fn margin_y(mut self, y: f32) -> Self {
396        let y = sanitize_margin(y);
397        self.margin.top = y;
398        self.margin.bottom = y;
399        self
400    }
401
402    pub fn margin_left(mut self, v: f32) -> Self {
403        self.margin.left = sanitize_margin(v);
404        self
405    }
406
407    pub fn margin_right(mut self, v: f32) -> Self {
408        self.margin.right = sanitize_margin(v);
409        self
410    }
411
412    pub fn margin_top(mut self, v: f32) -> Self {
413        self.margin.top = sanitize_margin(v);
414        self
415    }
416
417    pub fn margin_bottom(mut self, v: f32) -> Self {
418        self.margin.bottom = sanitize_margin(v);
419        self
420    }
421
422    pub fn scrim(&self) -> ModalScrim {
423        let style = self.style.unwrap_or_default();
424        ModalScrim {
425            open: self.open.clone(),
426            scrim: self.scrim.or(style.scrim).unwrap_or(DEFAULT_SCRIM),
427            backdrop: self
428                .scrim_backdrop
429                .or(style.backdrop)
430                .unwrap_or(Backdrop::NONE),
431        }
432    }
433
434    pub fn is_open(&self) -> bool {
435        self.open.get_untracked()
436    }
437
438    pub fn open(&self) {
439        self.open.set(true);
440    }
441
442    pub fn close(&self) {
443        self.open.set(false);
444    }
445
446    pub fn toggle(&self) {
447        self.open.set(!self.open.get_untracked());
448    }
449
450    fn key(&self) -> usize {
451        self.open.id()
452    }
453
454    fn sync_overlay(&self, ctx: &mut PaintCtx, theme: &Theme) {
455        let key = self.key();
456        if !self.open.get_untracked() {
457            if let Some(id) = take_entry(key) {
458                ctx.hide_overlay(id);
459            }
460            return;
461        }
462        if let Some(id) = get_entry(key) {
463            if !ctx.has_overlay(id) {
464                take_entry(key);
465                self.open.set(false);
466            }
467            return;
468        }
469        let Some(child) = self.child.clone() else {
470            self.open.set(false);
471            return;
472        };
473        let style = self.style.unwrap_or_default();
474        let radius = self.radius.or(style.radius).unwrap_or(theme.radius);
475        let backdrop = self.backdrop.or(style.backdrop).unwrap_or(Backdrop::MD);
476        let panel = OverlayPanel {
477            child,
478            width: self.width,
479            height: self.height,
480            padding: self.padding,
481            background: self
482                .background
483                .or(style.background)
484                .unwrap_or(theme.surface),
485            border: self.border.or(style.border).unwrap_or(theme.border),
486            border_width: self
487                .border_width
488                .or(style.border_width)
489                .unwrap_or(1.0)
490                .max(0.0),
491            radius,
492            backdrop,
493        };
494
495        let enter = Presence::new(Signal::new(true))
496            .from(0.0)
497            .enter_duration(MODAL_ENTER_SECS)
498            .enter_easing(MODAL_ENTER_EASE)
499            .slide(0.0, MODAL_ENTER_DY)
500            .child(panel);
501        let id = ctx.show_overlay(
502            OverlayEntry::new(enter)
503                .anchor(Anchor::Viewport)
504                .placement(Placement::Center)
505                .layer(OverlayLayer::Modal)
506                .input_mode(InputMode::Modal)
507                .dismiss(self.dismiss)
508                .overflow(OverlayOverflow::Clamp),
509        );
510        insert_entry(key, id);
511    }
512}
513
514impl View for Modal {
515    fn body(&self) -> impl View + 'static {
516        Unit
517    }
518    fn is_leaf(&self) -> bool {
519        true
520    }
521
522    fn hit_testable(&self) -> bool {
523        false
524    }
525    fn keyed_children(&self) -> Option<Vec<(u64, AnyView)>> {
526        let _ = self.open.get();
527        Some(Vec::new())
528    }
529    fn layout_style(&self) -> LayoutStyle {
530        LayoutStyle {
531            width: Length::Fit,
532            height: Length::Fit,
533            margin: self.margin,
534            ..LayoutStyle::default()
535        }
536    }
537    fn on_unmount(&self) {
538        take_entry(self.key());
539    }
540    fn paint_self(&self, ctx: &mut PaintCtx) {
541        let theme = ctx.theme();
542        self.sync_overlay(ctx, &theme);
543    }
544}
545
546fn sanitize_margin(v: f32) -> f32 {
547    if v.is_finite() { v.max(0.0) } else { 0.0 }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn scrim_clock_is_stable_across_rebuilds() {
556        let open = Signal::new(true);
557        let a = scrim_clock(open.id());
558        let b = scrim_clock(open.id());
559        assert!(Rc::ptr_eq(&a, &b), "same signal = same clock");
560
561        let (v, _) = fade_in_opacity(&a, SCRIM_FADE_SECS);
562        let (v2, _) = fade_in_opacity(&b, SCRIM_FADE_SECS);
563        assert!(v2 >= v, "fade does not undo between rebuilds: {v} -> {v2}");
564        forget_scrim_clock(open.id());
565    }
566
567    #[test]
568    fn scrim_clock_resets_after_close() {
569        let open = Signal::new(true);
570        let a = scrim_clock(open.id());
571        let _ = fade_in_opacity(&a, SCRIM_FADE_SECS);
572        forget_scrim_clock(open.id());
573        let b = scrim_clock(open.id());
574        assert!(!Rc::ptr_eq(&a, &b), "after closing, the clock is fresh");
575        assert!(b.borrow().is_none(), "fresh clock is not anchored");
576        forget_scrim_clock(open.id());
577    }
578}