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