Skip to main content

repose_ui/
overlay.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::collections::VecDeque;
4use std::rc::{Rc, Weak};
5
6use repose_core::{Modifier, View, ViewKind, request_frame};
7use web_time::{Duration, Instant};
8
9// Registry of live snackbar controllers, held weakly so a dropped
10// controller stops ticking without explicit teardown. Dead entries are
11// pruned on every read (`tick_all` / `next_deadline`).
12thread_local! {
13    static SNACKBAR_REGISTRY: RefCell<Vec<Weak<SnackbarControllerInner>>> =
14        const { RefCell::new(Vec::new()) };
15    static AMBIENT_OVERLAY: RefCell<Option<OverlayHandle>> = const { RefCell::new(None) };
16}
17
18/// Run `f` with `handle` installed as the ambient overlay for this frame.
19///
20/// The platform runtime wraps composition in this, so floating surfaces
21/// (dropdowns, tooltips, dialogs, snackbars) resolve their layer without
22/// the caller threading an `OverlayHandle` through every view. An
23/// explicitly passed handle still works as an escape hatch.
24pub fn with_ambient_overlay<R>(handle: OverlayHandle, f: impl FnOnce() -> R) -> R {
25    struct Guard;
26    impl Drop for Guard {
27        fn drop(&mut self) {
28            AMBIENT_OVERLAY.with(|slot| *slot.borrow_mut() = None);
29        }
30    }
31    AMBIENT_OVERLAY.with(|slot| *slot.borrow_mut() = Some(handle));
32    let _guard = Guard;
33    f()
34}
35
36/// The ambient [`OverlayHandle`] installed by the runtime, if any.
37pub fn ambient_overlay() -> Option<OverlayHandle> {
38    AMBIENT_OVERLAY.with(|slot| slot.borrow().clone())
39}
40
41/// Duration of the snackbar exit animation before the overlay entry is removed.
42const SNACKBAR_EXIT_ANIM_MS: u64 = 200;
43
44#[derive(Clone)]
45pub struct OverlayHandle {
46    inner: Rc<RefCell<OverlayState>>,
47}
48
49pub type BackHandler = Rc<dyn Fn() -> bool>;
50
51struct OverlayState {
52    next_id: u64,
53    entries: Vec<OverlayEntry>,
54    back_handlers: HashMap<u64, BackHandler>,
55}
56
57#[derive(Clone)]
58pub struct OverlayEntry {
59    pub id: u64,
60    pub builder: Rc<dyn Fn() -> View>,
61    pub z_index: f32,
62    pub pass_through: bool,
63}
64
65impl Default for OverlayHandle {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl OverlayHandle {
72    pub fn new() -> Self {
73        Self {
74            inner: Rc::new(RefCell::new(OverlayState {
75                next_id: 1,
76                entries: Vec::new(),
77                back_handlers: HashMap::new(),
78            })),
79        }
80    }
81
82    pub fn show(&self, view: View) -> u64 {
83        self.show_with(view, 0.0, false)
84    }
85
86    pub fn show_with(&self, view: View, z_index: f32, pass_through: bool) -> u64 {
87        let builder = Rc::new(move || view.clone());
88        self.show_entry(builder, z_index, pass_through)
89    }
90
91    pub fn show_with_back(
92        &self,
93        view: View,
94        z_index: f32,
95        pass_through: bool,
96        back_handler: BackHandler,
97    ) -> u64 {
98        let builder = Rc::new(move || view.clone());
99        self.show_entry_with_back(builder, z_index, pass_through, Some(back_handler))
100    }
101
102    pub fn show_entry(
103        &self,
104        builder: Rc<dyn Fn() -> View>,
105        z_index: f32,
106        pass_through: bool,
107    ) -> u64 {
108        self.show_entry_with_back(builder, z_index, pass_through, None)
109    }
110
111    pub fn show_entry_with_back(
112        &self,
113        builder: Rc<dyn Fn() -> View>,
114        z_index: f32,
115        pass_through: bool,
116        back_handler: Option<BackHandler>,
117    ) -> u64 {
118        let mut inner = self.inner.borrow_mut();
119        let id = inner.next_id;
120        inner.next_id += 1;
121        inner.entries.push(OverlayEntry {
122            id,
123            builder,
124            z_index,
125            pass_through,
126        });
127        if let Some(back_handler) = back_handler {
128            inner.back_handlers.insert(id, back_handler);
129        }
130        request_frame();
131        id
132    }
133
134    /// Show `builder` and return a guard owning the entry. Dropping the
135    /// guard dismisses the entry; see [`OverlayGuard`].
136    pub fn show_guard(
137        &self,
138        builder: Rc<dyn Fn() -> View>,
139        z_index: f32,
140        pass_through: bool,
141    ) -> OverlayGuard {
142        OverlayGuard::show(self, builder, z_index, pass_through)
143    }
144
145    pub fn show_guard_with_back(
146        &self,
147        builder: Rc<dyn Fn() -> View>,
148        z_index: f32,
149        pass_through: bool,
150        back_handler: BackHandler,
151    ) -> OverlayGuard {
152        OverlayGuard::show_with_back(self, builder, z_index, pass_through, back_handler)
153    }
154
155    pub fn handle_back(&self) -> bool {
156        let handlers = {
157            let inner = self.inner.borrow();
158            let mut handlers = inner
159                .entries
160                .iter()
161                .enumerate()
162                .filter_map(|(order, entry)| {
163                    inner
164                        .back_handlers
165                        .get(&entry.id)
166                        .map(|handler| (order, entry.z_index, (*handler).clone()))
167                })
168                .collect::<Vec<_>>();
169            handlers.sort_by(|left, right| {
170                right
171                    .1
172                    .partial_cmp(&left.1)
173                    .unwrap_or(std::cmp::Ordering::Equal)
174                    .then_with(|| right.0.cmp(&left.0))
175            });
176            handlers
177                .into_iter()
178                .map(|(_, _, handler)| handler)
179                .collect::<Vec<_>>()
180        };
181        handlers.into_iter().any(|handler| handler())
182    }
183
184    /// Number of live entries. Reads state directly rather than going through
185    /// [`Self::host`], which rebuilds every entry and so cannot be used to
186    /// observe what is registered.
187    pub fn entry_count(&self) -> usize {
188        self.inner.borrow().entries.len()
189    }
190
191    pub fn dismiss(&self, id: u64) -> bool {
192        let mut inner = self.inner.borrow_mut();
193        let before = inner.entries.len();
194        inner.entries.retain(|entry| entry.id != id);
195        let removed = inner.entries.len() != before;
196        inner.back_handlers.remove(&id);
197        if removed {
198            request_frame();
199        }
200        removed
201    }
202
203    pub fn clear(&self) {
204        let mut inner = self.inner.borrow_mut();
205        let had_entries = !inner.entries.is_empty();
206        inner.entries.clear();
207        inner.back_handlers.clear();
208        if had_entries {
209            request_frame();
210        }
211    }
212
213    /// Hoist `content` plus this handle's entries under one root.
214    ///
215    /// The runtime calls this implicitly around the app root each frame
216    /// (see [`with_ambient_overlay`]); explicit `host()` calls remain for
217    /// tests and advanced layering.
218    pub fn host(&self, modifier: Modifier, content: View) -> View {
219        let mut root = View::new(0, ViewKind::OverlayHost).modifier(modifier);
220        root.children.push(content);
221        let mut overlays = self.inner.borrow().entries.clone();
222        // Sort ascending by z_index so higher z_index overlays are painted last (on top)
223        overlays.sort_by(|a, b| {
224            a.z_index
225                .partial_cmp(&b.z_index)
226                .unwrap_or(std::cmp::Ordering::Equal)
227        });
228
229        for entry in overlays {
230            let view = (entry.builder)();
231            let mut modifier = view
232                .modifier
233                .clone()
234                .z_index(entry.z_index)
235                .render_z_index(entry.z_index + 1000.0);
236            if entry.pass_through {
237                modifier = modifier.hit_passthrough();
238            }
239            root.children.push(view.modifier(modifier));
240        }
241        root
242    }
243}
244
245/// RAII owner of a shown overlay entry.
246///
247/// Dropping the guard dismisses the entry, so callers can't leak entries by
248/// forgetting `dismiss` or mismatching a `0`-sentinel id. The typical shape
249/// is a `remember`-ed `RefCell<Option<OverlayGuard>>`: set it to `Some` when
250/// the overlay should show, back to `None` to hide.
251///
252/// The guard holds only the handle + id (no entry content), so it never
253/// participates in reference cycles with the overlay state.
254#[must_use = "dropping the guard dismisses the overlay entry"]
255pub struct OverlayGuard {
256    handle: OverlayHandle,
257    id: Option<u64>,
258}
259
260impl OverlayGuard {
261    /// Show `builder` on `handle` and own the resulting entry.
262    pub fn show(
263        handle: &OverlayHandle,
264        builder: Rc<dyn Fn() -> View>,
265        z_index: f32,
266        pass_through: bool,
267    ) -> Self {
268        let id = handle.show_entry(builder, z_index, pass_through);
269        Self {
270            handle: handle.clone(),
271            id: Some(id),
272        }
273    }
274
275    pub fn show_with_back(
276        handle: &OverlayHandle,
277        builder: Rc<dyn Fn() -> View>,
278        z_index: f32,
279        pass_through: bool,
280        back_handler: BackHandler,
281    ) -> Self {
282        let id = handle.show_entry_with_back(builder, z_index, pass_through, Some(back_handler));
283        Self {
284            handle: handle.clone(),
285            id: Some(id),
286        }
287    }
288
289    /// Entry id, or `None` after [`dismiss`](Self::dismiss).
290    pub fn id(&self) -> Option<u64> {
291        self.id
292    }
293
294    /// Dismiss now instead of at drop. Consuming the guard makes a
295    /// double-dismiss impossible.
296    pub fn dismiss(mut self) {
297        self.dismiss_now();
298    }
299
300    fn dismiss_now(&mut self) {
301        if let Some(id) = self.id.take() {
302            self.handle.dismiss(id);
303        }
304    }
305}
306
307impl Drop for OverlayGuard {
308    fn drop(&mut self) {
309        self.dismiss_now();
310    }
311}
312
313#[derive(Clone)]
314pub struct SnackbarController {
315    inner: Rc<SnackbarControllerInner>,
316}
317
318struct SnackbarControllerInner {
319    state: RefCell<SnackbarState>,
320    overlay: OverlayHandle,
321}
322
323#[derive(Clone)]
324pub struct SnackbarAction {
325    pub label: String,
326    pub on_click: Rc<dyn Fn()>,
327}
328
329#[derive(Clone)]
330pub struct SnackbarRequest {
331    pub message: String,
332    pub action: Option<SnackbarAction>,
333    pub duration_ms: u32,
334    /// Called on every frame with whether the snackbar is in its
335    /// exit-animation phase, so the view can animate out.
336    pub builder: Rc<dyn Fn(bool) -> View>,
337}
338
339struct SnackbarState {
340    queue: VecDeque<SnackbarRequest>,
341    active: Option<ActiveSnackbar>,
342}
343
344/// Lifecycle phase of the visible snackbar. Both phases carry the `Instant`
345/// at which the snackbar leaves the phase (dismiss start, then removal).
346enum SnackbarPhase {
347    Showing { deadline: Instant },
348    Dismissing { deadline: Instant },
349}
350
351struct ActiveSnackbar {
352    /// Owns the overlay entry; clearing `active` dismisses the entry via drop.
353    /// Never read by name: its `Drop` impl is the whole point.
354    #[allow(dead_code)]
355    guard: OverlayGuard,
356    message: String,
357    action: Option<SnackbarAction>,
358    phase: SnackbarPhase,
359}
360
361impl ActiveSnackbar {
362    fn deadline(&self) -> Instant {
363        match self.phase {
364            SnackbarPhase::Showing { deadline } | SnackbarPhase::Dismissing { deadline } => {
365                deadline
366            }
367        }
368    }
369
370    fn is_dismissing(&self) -> bool {
371        matches!(self.phase, SnackbarPhase::Dismissing { .. })
372    }
373
374    /// Idempotent transition into the exit animation.
375    fn start_dismiss(&mut self, now: Instant) {
376        if !self.is_dismissing() {
377            self.phase = SnackbarPhase::Dismissing {
378                deadline: now + Duration::from_millis(SNACKBAR_EXIT_ANIM_MS),
379            };
380            request_frame();
381        }
382    }
383}
384
385impl SnackbarController {
386    pub fn new(overlay: OverlayHandle) -> Self {
387        Self::with_handle(overlay)
388    }
389
390    /// Resolve the ambient handle installed by the runtime
391    /// ([`with_ambient_overlay`]), falling back to a fresh handle when
392    /// composition runs outside a runtime frame (tests, previews).
393    pub fn ambient() -> Self {
394        let overlay = ambient_overlay().unwrap_or_default();
395        Self::with_handle(overlay)
396    }
397
398    fn with_handle(overlay: OverlayHandle) -> Self {
399        let controller = Self {
400            inner: Rc::new(SnackbarControllerInner {
401                state: RefCell::new(SnackbarState {
402                    queue: VecDeque::new(),
403                    active: None,
404                }),
405                overlay,
406            }),
407        };
408
409        SNACKBAR_REGISTRY.with(|reg| reg.borrow_mut().push(Rc::downgrade(&controller.inner)));
410        controller
411    }
412
413    /// The handle this controller posts entries to.
414    pub fn handle(&self) -> OverlayHandle {
415        self.inner.overlay.clone()
416    }
417
418    fn live_controllers() -> Vec<Rc<SnackbarControllerInner>> {
419        SNACKBAR_REGISTRY.with(|reg| {
420            let mut reg = reg.borrow_mut();
421            reg.retain(|w| w.upgrade().is_some());
422            reg.iter().filter_map(Weak::upgrade).collect()
423        })
424    }
425
426    /// Tick all live controllers once. Call once per redraw.
427    pub fn tick_all() {
428        for inner in Self::live_controllers() {
429            Self { inner }.tick();
430        }
431    }
432
433    /// Earliest `Instant` when any snackbar needs to wake (dismiss start or
434    /// finish). `None` if no snackbar is active.
435    pub fn next_deadline() -> Option<Instant> {
436        Self::live_controllers()
437            .iter()
438            .filter_map(|inner| {
439                inner
440                    .state
441                    .borrow()
442                    .active
443                    .as_ref()
444                    .map(ActiveSnackbar::deadline)
445            })
446            .min()
447    }
448
449    pub fn show(&self, request: SnackbarRequest) {
450        if self.inner.state.borrow().active.is_some() {
451            self.inner.state.borrow_mut().queue.push_back(request);
452        } else {
453            self.activate_next(request);
454        }
455    }
456
457    pub fn tick(&self) {
458        let now = Instant::now();
459        let mut finished: Option<ActiveSnackbar> = None;
460        {
461            let mut state = self.inner.state.borrow_mut();
462            if let Some(active) = state.active.as_mut() {
463                match active.phase {
464                    SnackbarPhase::Showing { deadline } if now >= deadline => {
465                        active.start_dismiss(now);
466                    }
467                    SnackbarPhase::Dismissing { deadline } if now >= deadline => {
468                        finished = state.active.take();
469                    }
470                    _ => {}
471                }
472            }
473        }
474        drop(finished);
475        self.activate_next_if_needed();
476    }
477
478    pub fn dismiss(&self) {
479        let now = Instant::now();
480        if let Some(active) = self.inner.state.borrow_mut().active.as_mut() {
481            active.start_dismiss(now);
482        }
483    }
484
485    pub fn current(&self) -> Option<(String, Option<SnackbarAction>)> {
486        let inner = self.inner.state.borrow();
487        inner
488            .active
489            .as_ref()
490            .map(|active| (active.message.clone(), active.action.clone()))
491    }
492
493    fn activate_next_if_needed(&self) {
494        if self.inner.state.borrow().active.is_some() {
495            return;
496        }
497        let req = self.inner.state.borrow_mut().queue.pop_front();
498        if let Some(req) = req {
499            self.activate_next(req);
500        }
501    }
502
503    fn activate_next(&self, req: SnackbarRequest) {
504        if self.inner.state.borrow().active.is_some() {
505            return;
506        }
507        let weak = Rc::downgrade(&self.inner);
508        let original_builder = req.builder.clone();
509        let wrapped_builder: Rc<dyn Fn() -> View> = Rc::new(move || {
510            let dismissing = weak
511                .upgrade()
512                .and_then(|inner| {
513                    inner
514                        .state
515                        .borrow()
516                        .active
517                        .as_ref()
518                        .map(ActiveSnackbar::is_dismissing)
519                })
520                .unwrap_or(false);
521            (original_builder)(dismissing)
522        });
523        let action = req.action.map(|a| SnackbarAction {
524            on_click: {
525                let original = a.on_click;
526                let controller = self.clone();
527                Rc::new(move || {
528                    (original)();
529                    controller.dismiss();
530                })
531            },
532            label: a.label,
533        });
534        let guard = OverlayGuard::show(&self.inner.overlay, wrapped_builder, 900.0, true);
535        let deadline = Instant::now() + Duration::from_millis(u64::from(req.duration_ms.max(1)));
536        self.inner.state.borrow_mut().active = Some(ActiveSnackbar {
537            guard,
538            message: req.message,
539            action,
540            phase: SnackbarPhase::Showing { deadline },
541        });
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use std::cell::Cell;
549
550    fn test_request() -> SnackbarRequest {
551        SnackbarRequest {
552            message: "hi".into(),
553            action: None,
554            duration_ms: 60_000,
555            builder: Rc::new(|_| View::new(0, ViewKind::OverlayHost)),
556        }
557    }
558
559    #[test]
560    fn dropped_controller_is_pruned_from_registry() {
561        {
562            let overlay = OverlayHandle::new();
563            let controller = SnackbarController::new(overlay);
564            controller.show(test_request());
565            SnackbarController::tick_all();
566            assert!(SnackbarController::next_deadline().is_some());
567        }
568        SnackbarController::tick_all();
569        assert!(SnackbarController::next_deadline().is_none());
570    }
571
572    #[test]
573    fn dismiss_schedules_exit_deadline() {
574        let overlay = OverlayHandle::new();
575        let controller = SnackbarController::new(overlay);
576        controller.show(test_request());
577        let before = Instant::now();
578        controller.dismiss();
579        let deadline = SnackbarController::next_deadline().expect("deadline while dismissing");
580        assert!(deadline >= before);
581        assert!(deadline.saturating_duration_since(before) <= Duration::from_millis(500));
582        controller.dismiss();
583        let again = SnackbarController::next_deadline().expect("deadline while dismissing");
584        assert_eq!(deadline, again);
585        drop(controller);
586        SnackbarController::tick_all();
587    }
588
589    #[test]
590    fn back_uses_topmost_handler_and_falls_back_after_dismiss() {
591        let overlay = OverlayHandle::new();
592        let lower = Rc::new(Cell::new(0u32));
593        let upper = Rc::new(Cell::new(0u32));
594        let lower_handler = lower.clone();
595        let upper_handler = upper.clone();
596        let builder: Rc<dyn Fn() -> View> = Rc::new(|| View::new(0, ViewKind::OverlayHost));
597        let lower_id = overlay.show_entry_with_back(
598            builder.clone(),
599            1.0,
600            false,
601            Some(Rc::new(move || {
602                lower_handler.set(lower_handler.get() + 1);
603                true
604            })),
605        );
606        overlay.show_entry_with_back(
607            builder,
608            2.0,
609            false,
610            Some(Rc::new(move || {
611                upper_handler.set(upper_handler.get() + 1);
612                true
613            })),
614        );
615        assert!(overlay.handle_back());
616        assert_eq!(upper.get(), 1);
617        assert_eq!(lower.get(), 0);
618        overlay.dismiss(lower_id);
619        assert!(overlay.handle_back());
620        assert_eq!(upper.get(), 2);
621    }
622
623    #[test]
624    fn guard_drop_dismisses_entry() {
625        let overlay = OverlayHandle::new();
626        let builder: Rc<dyn Fn() -> View> = Rc::new(|| View::new(0, ViewKind::OverlayHost));
627        {
628            let guard = overlay.show_guard(builder.clone(), 5.0, false);
629            assert!(guard.id().is_some());
630            assert_eq!(overlay.inner.borrow().entries.len(), 1);
631            guard.dismiss();
632            assert!(overlay.inner.borrow().entries.is_empty());
633        }
634        let guard = overlay.show_guard(builder, 5.0, false);
635        assert_eq!(overlay.inner.borrow().entries.len(), 1);
636        drop(guard);
637        assert!(overlay.inner.borrow().entries.is_empty());
638    }
639}