Skip to main content

sequoia_view/
portal.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::rc::Rc;
4
5use sequoia_core::{Length, Rect};
6use sequoia_input::{Event, Key};
7use sequoia_layout::LayoutStyle;
8use sequoia_reactive::Signal;
9
10use crate::any::AnyView;
11use crate::overlay::{
12    Anchor, Dismiss, InputMode, OverlayEntry, OverlayId, OverlayLayer, OverlayOverflow,
13};
14use crate::view::{EventCtx, PaintCtx, View};
15use crate::views::Unit;
16
17thread_local! {
18    static PORTAL_ENTRIES: RefCell<HashMap<usize, (OverlayId, Option<crate::runtime::ElementId>)>> = RefCell::new(HashMap::new());
19    static POPPER_ENTRIES: RefCell<HashMap<usize, (OverlayId, Option<crate::runtime::ElementId>)>> = RefCell::new(HashMap::new());
20}
21
22fn get_portal_entry(key: usize) -> Option<(OverlayId, Option<crate::runtime::ElementId>)> {
23    PORTAL_ENTRIES.with(|m| m.borrow().get(&key).copied())
24}
25
26fn insert_portal_entry(key: usize, id: OverlayId, anchor: Option<crate::runtime::ElementId>) {
27    PORTAL_ENTRIES.with(|m| {
28        m.borrow_mut().insert(key, (id, anchor));
29    });
30}
31
32fn take_portal_entry(key: usize) -> Option<OverlayId> {
33    PORTAL_ENTRIES.with(|m| m.borrow_mut().remove(&key).map(|(id, _)| id))
34}
35
36fn get_popper_entry(key: usize) -> Option<(OverlayId, Option<crate::runtime::ElementId>)> {
37    POPPER_ENTRIES.with(|m| m.borrow().get(&key).copied())
38}
39
40fn insert_popper_entry(key: usize, id: OverlayId, anchor: Option<crate::runtime::ElementId>) {
41    POPPER_ENTRIES.with(|m| {
42        m.borrow_mut().insert(key, (id, anchor));
43    });
44}
45
46fn take_popper_entry(key: usize) -> Option<OverlayId> {
47    POPPER_ENTRIES.with(|m| m.borrow_mut().remove(&key).map(|(id, _)| id))
48}
49
50pub struct Portal {
51    open: Signal<bool>,
52    child: Option<AnyView>,
53    layer: OverlayLayer,
54    input_mode: InputMode,
55    dismiss: Dismiss,
56    anchor: Anchor,
57    anchor_self: bool,
58    placement: crate::overlay::Placement,
59    offset: (f32, f32),
60    overflow: OverlayOverflow,
61    on_dismiss: Option<Rc<dyn Fn()>>,
62}
63
64impl Portal {
65    pub fn new(open: Signal<bool>) -> Self {
66        Self {
67            open,
68            child: None,
69            layer: OverlayLayer::Popup,
70            input_mode: InputMode::Interactive,
71            dismiss: Dismiss::Manual,
72            anchor: Anchor::Viewport,
73            anchor_self: false,
74            placement: crate::overlay::Placement::Center,
75            offset: (0.0, 0.0),
76            overflow: OverlayOverflow::FlipAndClamp,
77            on_dismiss: None,
78        }
79    }
80
81    pub fn child<V: View + 'static>(mut self, child: V) -> Self {
82        self.child = Some(AnyView::new(child));
83        self
84    }
85
86    pub fn layer(mut self, layer: OverlayLayer) -> Self {
87        self.layer = layer;
88        self
89    }
90
91    pub fn input_mode(mut self, mode: InputMode) -> Self {
92        self.input_mode = mode;
93        self
94    }
95
96    pub fn dismiss(mut self, dismiss: Dismiss) -> Self {
97        self.dismiss = dismiss;
98        self
99    }
100
101    pub fn anchor(mut self, anchor: Anchor) -> Self {
102        self.anchor = anchor;
103        self.anchor_self = false;
104        self
105    }
106
107    pub fn anchor_self(mut self) -> Self {
108        self.anchor_self = true;
109        self
110    }
111
112    pub fn placement(mut self, placement: crate::overlay::Placement) -> Self {
113        self.placement = placement;
114        self
115    }
116
117    pub fn offset(mut self, dx: f32, dy: f32) -> Self {
118        self.offset = (dx, dy);
119        self
120    }
121
122    pub fn overflow(mut self, overflow: OverlayOverflow) -> Self {
123        self.overflow = overflow;
124        self
125    }
126
127    pub fn on_dismiss(mut self, f: impl Fn() + 'static) -> Self {
128        self.on_dismiss = Some(Rc::new(f));
129        self
130    }
131
132    pub fn is_open(&self) -> bool {
133        self.open.get_untracked()
134    }
135
136    pub fn open(&self) {
137        self.open.set(true);
138    }
139
140    pub fn close(&self) {
141        self.open.set(false);
142    }
143
144    pub fn toggle(&self) {
145        self.open.set(!self.open.get_untracked());
146    }
147
148    fn key(&self) -> usize {
149        self.open.id()
150    }
151
152    fn sync_overlay(&self, ctx: &mut PaintCtx) {
153        let key = self.key();
154        if !self.open.get_untracked() {
155            if let Some(id) = take_portal_entry(key) {
156                ctx.hide_overlay(id);
157            }
158            return;
159        }
160        let self_anchor = if self.anchor_self {
161            Some(ctx.element)
162        } else {
163            None
164        };
165        if let Some((id, anchor)) = get_portal_entry(key) {
166            if anchor != self_anchor {
167                if ctx.has_overlay(id) {
168                    ctx.hide_overlay(id);
169                }
170                take_portal_entry(key);
171            } else {
172                if !ctx.has_overlay(id) {
173                    take_portal_entry(key);
174                    self.open.set(false);
175                    if let Some(f) = &self.on_dismiss {
176                        f();
177                    }
178                }
179                return;
180            }
181        }
182        let Some(child) = self.child.clone() else {
183            self.open.set(false);
184            return;
185        };
186        let anchor = if self.anchor_self {
187            Anchor::View(ctx.element)
188        } else {
189            self.anchor
190        };
191        let id = ctx.show_overlay(
192            OverlayEntry::new(PortalBody { child })
193                .anchor(anchor)
194                .placement(self.placement)
195                .layer(self.layer)
196                .input_mode(self.input_mode)
197                .dismiss(self.dismiss)
198                .offset(self.offset.0, self.offset.1)
199                .overflow(self.overflow),
200        );
201        insert_portal_entry(key, id, self_anchor);
202    }
203}
204
205impl View for Portal {
206    fn body(&self) -> impl View + 'static {
207        Unit
208    }
209    fn is_leaf(&self) -> bool {
210        true
211    }
212
213    fn hit_testable(&self) -> bool {
214        false
215    }
216    fn keyed_children(&self) -> Option<Vec<(u64, AnyView)>> {
217        let _ = self.open.get();
218        Some(Vec::new())
219    }
220    fn layout_style(&self) -> LayoutStyle {
221        LayoutStyle {
222            width: Length::Fit,
223            height: Length::Fit,
224            ..LayoutStyle::default()
225        }
226    }
227    fn on_unmount(&self) {
228        take_portal_entry(self.key());
229    }
230    fn paint_self(&self, ctx: &mut PaintCtx) {
231        self.sync_overlay(ctx);
232    }
233}
234
235struct PortalBody {
236    child: AnyView,
237}
238
239impl View for PortalBody {
240    fn body(&self) -> impl View + 'static {
241        Unit
242    }
243    fn children_override(&self) -> Option<Vec<AnyView>> {
244        Some(vec![self.child.clone()])
245    }
246    fn layout_style(&self) -> LayoutStyle {
247        LayoutStyle {
248            width: Length::Fit,
249            height: Length::Fit,
250            ..LayoutStyle::default()
251        }
252    }
253}
254
255pub struct Popper {
256    open: Signal<bool>,
257    child: Option<AnyView>,
258    anchor: Anchor,
259    anchor_self: bool,
260    placement: crate::overlay::Placement,
261    offset: (f32, f32),
262    overflow: OverlayOverflow,
263    layer: OverlayLayer,
264    input_mode: InputMode,
265    dismiss: Dismiss,
266    on_dismiss: Option<Rc<dyn Fn()>>,
267}
268
269impl Popper {
270    pub fn new(open: Signal<bool>) -> Self {
271        Self {
272            open,
273            child: None,
274            anchor: Anchor::Viewport,
275            anchor_self: false,
276            placement: crate::overlay::Placement::Bottom,
277            offset: (0.0, 4.0),
278            overflow: OverlayOverflow::FlipAndClamp,
279            layer: OverlayLayer::Popup,
280            input_mode: InputMode::Interactive,
281            dismiss: Dismiss::OutsideClick,
282            on_dismiss: None,
283        }
284    }
285
286    pub fn child<V: View + 'static>(mut self, child: V) -> Self {
287        self.child = Some(AnyView::new(child));
288        self
289    }
290
291    pub fn anchor(mut self, anchor: Anchor) -> Self {
292        self.anchor = anchor;
293        self.anchor_self = false;
294        self
295    }
296
297    pub fn anchor_rect(mut self, rect: Rect) -> Self {
298        self.anchor = Anchor::Rect(rect);
299        self.anchor_self = false;
300        self
301    }
302
303    pub fn anchor_view(mut self, id: crate::runtime::ElementId) -> Self {
304        self.anchor = Anchor::View(id);
305        self.anchor_self = false;
306        self
307    }
308
309    /// Anchors the overlay to the popper's own layout box.
310    ///
311    /// The popper is usually a zero-sized box in its parent, so this anchors
312    /// the overlay near wherever the `Popper` happens to sit in the tree, not
313    /// to the control the user clicked. For a menu or tooltip under a button,
314    /// store the button's `ElementId` (for example `ctx.element()` from its
315    /// paint pass) and use [`Self::anchor_view`] instead; that stays correct
316    /// when the button moves or the layout changes.
317    pub fn anchor_self(mut self) -> Self {
318        self.anchor_self = true;
319        self
320    }
321
322    pub fn at_cursor(mut self) -> Self {
323        self.anchor = Anchor::Cursor;
324        self.anchor_self = false;
325        self.placement = crate::overlay::Placement::Cursor;
326        self
327    }
328
329    /// Anchors the overlay to a fixed screen-space point.
330    ///
331    /// Unlike [`Self::at_cursor`], the point does not follow later pointer
332    /// movement. Use this for a context menu that should stay where the user
333    /// clicked. The default placement is [`crate::overlay::Placement::Cursor`],
334    /// so the overlay's top-left sits at the point.
335    pub fn anchor_point(mut self, x: f32, y: f32) -> Self {
336        self.anchor = Anchor::Point(sequoia_core::Point::new(x, y));
337        self.anchor_self = false;
338        self.placement = crate::overlay::Placement::Cursor;
339        self
340    }
341
342    /// Anchors the overlay to a fixed screen-space `Point`.
343    pub fn anchor_static(self, point: sequoia_core::Point) -> Self {
344        self.anchor_point(point.x, point.y)
345    }
346
347    pub fn placement(mut self, placement: crate::overlay::Placement) -> Self {
348        self.placement = placement;
349        self
350    }
351
352    pub fn offset(mut self, dx: f32, dy: f32) -> Self {
353        self.offset = (dx, dy);
354        self
355    }
356
357    pub fn overflow(mut self, overflow: OverlayOverflow) -> Self {
358        self.overflow = overflow;
359        self
360    }
361
362    pub fn layer(mut self, layer: OverlayLayer) -> Self {
363        self.layer = layer;
364        self
365    }
366
367    pub fn input_mode(mut self, mode: InputMode) -> Self {
368        self.input_mode = mode;
369        self
370    }
371
372    pub fn dismiss(mut self, dismiss: Dismiss) -> Self {
373        self.dismiss = dismiss;
374        self
375    }
376
377    pub fn on_dismiss(mut self, f: impl Fn() + 'static) -> Self {
378        self.on_dismiss = Some(Rc::new(f));
379        self
380    }
381
382    pub fn is_open(&self) -> bool {
383        self.open.get_untracked()
384    }
385
386    pub fn open(&self) {
387        self.open.set(true);
388    }
389
390    pub fn close(&self) {
391        self.open.set(false);
392    }
393
394    pub fn toggle(&self) {
395        self.open.set(!self.open.get_untracked());
396    }
397
398    fn key(&self) -> usize {
399        self.open.id()
400    }
401
402    fn sync_overlay(&self, ctx: &mut PaintCtx) {
403        let key = self.key();
404        if !self.open.get_untracked() {
405            if let Some(id) = take_popper_entry(key) {
406                ctx.hide_overlay(id);
407            }
408            return;
409        }
410        let self_anchor = if self.anchor_self {
411            Some(ctx.element)
412        } else {
413            None
414        };
415        if let Some((id, anchor)) = get_popper_entry(key) {
416            if anchor != self_anchor {
417                if ctx.has_overlay(id) {
418                    ctx.hide_overlay(id);
419                }
420                take_popper_entry(key);
421            } else {
422                if !ctx.has_overlay(id) {
423                    take_popper_entry(key);
424                    self.open.set(false);
425                    if let Some(f) = &self.on_dismiss {
426                        f();
427                    }
428                }
429                return;
430            }
431        }
432        let Some(child) = self.child.clone() else {
433            self.open.set(false);
434            return;
435        };
436        let anchor = if self.anchor_self {
437            Anchor::View(ctx.element)
438        } else {
439            self.anchor
440        };
441        let id = ctx.show_overlay(
442            OverlayEntry::new(PortalBody { child })
443                .anchor(anchor)
444                .placement(self.placement)
445                .layer(self.layer)
446                .input_mode(self.input_mode)
447                .dismiss(self.dismiss)
448                .offset(self.offset.0, self.offset.1)
449                .overflow(self.overflow),
450        );
451        insert_popper_entry(key, id, self_anchor);
452    }
453}
454
455impl View for Popper {
456    fn body(&self) -> impl View + 'static {
457        Unit
458    }
459    fn is_leaf(&self) -> bool {
460        true
461    }
462
463    fn hit_testable(&self) -> bool {
464        false
465    }
466    fn keyed_children(&self) -> Option<Vec<(u64, AnyView)>> {
467        let _ = self.open.get();
468        Some(Vec::new())
469    }
470    fn layout_style(&self) -> LayoutStyle {
471        LayoutStyle {
472            width: Length::Fit,
473            height: Length::Fit,
474            ..LayoutStyle::default()
475        }
476    }
477    fn on_unmount(&self) {
478        take_popper_entry(self.key());
479    }
480    fn paint_self(&self, ctx: &mut PaintCtx) {
481        self.sync_overlay(ctx);
482    }
483}
484
485pub struct DismissableLayer {
486    child: AnyView,
487    dismiss_on_escape: bool,
488    on_dismiss: Option<Rc<dyn Fn()>>,
489    on_escape: Option<Rc<dyn Fn()>>,
490}
491
492impl DismissableLayer {
493    pub fn wrap<V: View + 'static>(child: V) -> Self {
494        Self {
495            child: AnyView::new(child),
496            dismiss_on_escape: true,
497            on_dismiss: None,
498            on_escape: None,
499        }
500    }
501
502    pub fn dismiss_on_escape(mut self, on: bool) -> Self {
503        self.dismiss_on_escape = on;
504        self
505    }
506
507    pub fn on_dismiss(mut self, f: impl Fn() + 'static) -> Self {
508        self.on_dismiss = Some(Rc::new(f));
509        self
510    }
511
512    pub fn on_escape(mut self, f: impl Fn() + 'static) -> Self {
513        self.on_escape = Some(Rc::new(f));
514        self
515    }
516
517    pub fn bind(self, open: Signal<bool>) -> Self {
518        let prev = self.on_dismiss.clone();
519        self.on_dismiss(move || {
520            if let Some(f) = &prev {
521                f();
522            }
523            open.set(false);
524        })
525    }
526
527    fn fire_escape(&self, ctx: &mut EventCtx) {
528        if let Some(f) = &self.on_escape {
529            f();
530        }
531        if self.dismiss_on_escape {
532            if let Some(f) = &self.on_dismiss {
533                f();
534            }
535            ctx.handled();
536        }
537    }
538}
539
540impl View for DismissableLayer {
541    fn body(&self) -> impl View + 'static {
542        Unit
543    }
544    fn children_override(&self) -> Option<Vec<AnyView>> {
545        Some(vec![self.child.clone()])
546    }
547    fn layout_style(&self) -> LayoutStyle {
548        LayoutStyle {
549            width: Length::Fit,
550            height: Length::Fit,
551            ..LayoutStyle::default()
552        }
553    }
554    fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
555        let Event::Key(key) = event else { return };
556        if key.key != Key::ESCAPE {
557            return;
558        }
559
560        if !matches!(
561            ctx.phase,
562            sequoia_input::EventPhase::Target | sequoia_input::EventPhase::Bubble
563        ) {
564            return;
565        }
566        if !ctx.has_focus_within() && !ctx.state.focused {
567            return;
568        }
569        self.fire_escape(ctx);
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn portal_defaults_to_manual_center_popup() {
579        let open = Signal::new(false);
580        let p = Portal::new(open);
581        assert!(matches!(p.dismiss, Dismiss::Manual));
582        assert!(matches!(p.layer, OverlayLayer::Popup));
583        assert!(matches!(p.anchor, Anchor::Viewport));
584        assert!(!p.is_open());
585    }
586
587    #[test]
588    fn popper_defaults_to_outside_click_bottom_with_gap() {
589        let open = Signal::new(true);
590        let p = Popper::new(open);
591        assert!(matches!(p.dismiss, Dismiss::OutsideClick));
592        assert!(matches!(p.placement, crate::overlay::Placement::Bottom));
593        assert_eq!(p.offset, (0.0, 4.0));
594        assert!(matches!(p.overflow, OverlayOverflow::FlipAndClamp));
595        assert!(p.is_open());
596    }
597
598    #[test]
599    fn popper_at_cursor_sets_cursor_anchor_and_placement() {
600        let open = Signal::new(false);
601        let p = Popper::new(open).at_cursor();
602        assert!(matches!(p.anchor, Anchor::Cursor));
603        assert!(matches!(p.placement, crate::overlay::Placement::Cursor));
604    }
605
606    #[test]
607    fn popper_anchor_point_stays_static() {
608        let open = Signal::new(false);
609        let p = Popper::new(open).anchor_point(12.0, 34.0);
610        assert!(
611            matches!(p.anchor, Anchor::Point(pt) if pt == sequoia_core::Point::new(12.0, 34.0))
612        );
613        assert!(matches!(p.placement, crate::overlay::Placement::Cursor));
614
615        let p = Popper::new(Signal::new(false)).anchor_static(sequoia_core::Point::new(5.0, 6.0));
616        assert!(matches!(p.anchor, Anchor::Point(pt) if pt == sequoia_core::Point::new(5.0, 6.0)));
617    }
618
619    #[test]
620    fn dismissable_binds_open_signal() {
621        let open = Signal::new(true);
622        let layer = DismissableLayer::wrap(Unit).bind(open.clone());
623        assert!(layer.dismiss_on_escape);
624
625        if let Some(f) = &layer.on_dismiss {
626            f();
627        }
628        assert!(!open.get_untracked());
629    }
630
631    #[test]
632    fn dismissable_chains_previous_on_dismiss() {
633        let open = Signal::new(true);
634        let seen = Rc::new(std::cell::Cell::new(false));
635        let seen2 = seen.clone();
636        let layer = DismissableLayer::wrap(Unit)
637            .on_dismiss(move || seen2.set(true))
638            .bind(open.clone());
639        if let Some(f) = &layer.on_dismiss {
640            f();
641        }
642        assert!(seen.get());
643        assert!(!open.get_untracked());
644    }
645}