Skip to main content

frus_widgets/
portal.rs

1//! [`OverlayPortal`]: shows content **above** everything else (out of the layout flow,
2//! not clipped by parents). The basis for floating menus, tooltips and modals.
3
4use frus_core::{Rect, Scene};
5use frus_layout::Style;
6
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::Widget;
10
11/// Where to place the overlay relative to its anchor, or to the window.
12#[derive(Copy, Clone, Debug, PartialEq, Eq)]
13pub enum Placement {
14    /// Just below the anchor (a dropdown menu).
15    Below,
16    /// Centred in the window, with a scrim (a modal).
17    Center,
18    /// Above the anchor, **only while the anchor is hovered** (a tooltip).
19    Tooltip,
20    /// A full-height panel against the **left** edge of the window, with a
21    /// scrim (a side drawer). See [`crate::Drawer`].
22    Left,
23    /// A full-height panel against the **right** edge of the window, with a
24    /// scrim (a side drawer). See [`crate::Drawer`].
25    Right,
26    /// A full-width panel against the **bottom** edge of the window, sliding up,
27    /// with a scrim (a modal sheet). See [`crate::BottomSheet`].
28    Bottom,
29    /// Centred on the anchor and **above** it, or below it — clear of the handles that hang
30    /// there — when there is no room above; held inside the window. The bar over a text
31    /// selection (see [`crate::ToolbarItem`]), whose anchor is the selection's box rather
32    /// than a widget's.
33    Selection,
34    /// **Beside** the anchor, on its end side, tops level: a submenu opening from its row
35    /// (milestone 603). Where the window has no room there, it opens on the start side.
36    Beside,
37}
38
39/// A portal: an **anchor** (in the flow) and an optional floating **overlay**.
40pub struct OverlayPortal<Msg = crate::callback::Callback> {
41    /// `[anchor]` or `[anchor, overlay]`.
42    children: Vec<Box<dyn Widget<Msg>>>,
43    placement: Placement,
44    /// Message emitted when the scrim is clicked (a modal), to close it.
45    on_dismiss: Option<Msg>,
46}
47
48impl<Msg: 'static> OverlayPortal<Msg> {
49    /// Creates a portal around an anchor, which is rendered normally.
50    pub fn new(anchor: impl Widget<Msg> + 'static) -> Self {
51        Self::new_boxed(Box::new(anchor))
52    }
53
54    /// [`Self::new`], for an anchor already boxed — what a widget that *assembles* its
55    /// anchor has, rather than an `impl Widget` a caller typed out.
56    pub fn new_boxed(anchor: Box<dyn Widget<Msg>>) -> Self {
57        Self {
58            children: vec![Box::new(anchor)],
59            placement: Placement::Below,
60            on_dismiss: None,
61        }
62    }
63
64    /// Adds the floating content and its placement.
65    pub fn overlay(self, content: impl Widget<Msg> + 'static, placement: Placement) -> Self {
66        self.overlay_boxed(Box::new(content), placement)
67    }
68
69    /// [`Self::overlay`], for content already boxed.
70    pub fn overlay_boxed(mut self, content: Box<dyn Widget<Msg>>, placement: Placement) -> Self {
71        self.children.truncate(1);
72        self.children.push(Box::new(content));
73        self.placement = placement;
74        self
75    }
76
77    /// Message emitted on a click **outside** the content (on the scrim) — to close.
78    pub fn dismiss(mut self, message: impl Into<Msg>) -> Self {
79        self.on_dismiss = Some(message.into());
80        self
81    }
82}
83
84impl<Msg: Clone> Widget<Msg> for OverlayPortal<Msg> {
85    /// A column, so that the anchor is as wide as the portal is given: with nothing floating,
86    /// the portal is laid out as any container is, and in a row the anchor kept its own
87    /// width. (With something floating, the walk picks the axis from what the anchor fills,
88    /// milestone 607.)
89    fn style(&self) -> Style {
90        Style {
91            flex_direction: frus_layout::FlexDirection::Column,
92            ..Style::default()
93        }
94    }
95
96    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
97        &self.children
98    }
99
100    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
101
102    fn on_click(&self) -> Option<Msg> {
103        None
104    }
105
106    fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
107        self.children
108            .get(1)
109            .map(|content| (content.as_ref(), self.placement))
110    }
111
112    fn overlay_dismiss(&self) -> Option<Msg> {
113        self.on_dismiss.clone()
114    }
115
116    /// `Escape` closes the portal (if a dismiss message is configured) — consumed
117    /// while bubbling leaf→root when the focus is inside.
118    fn on_key(&self, key: &crate::Key) -> crate::KeyResponse<Msg> {
119        match (key, &self.on_dismiss) {
120            (crate::Key::Escape, Some(message)) => {
121                crate::KeyResponse::Handled(Some(message.clone()))
122            }
123            _ => crate::KeyResponse::Ignored,
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::{Container, Runtime, Size, Text};
132
133    #[test]
134    fn overlay_present_only_when_set() {
135        let bare: OverlayPortal<()> = OverlayPortal::new(Container::<()>::new());
136        assert!(Widget::<()>::overlay(&bare).is_none());
137
138        let with: OverlayPortal<()> =
139            OverlayPortal::new(Container::<()>::new()).overlay(Text::new("tip"), Placement::Center);
140        assert!(Widget::<()>::overlay(&with).is_some());
141    }
142
143    #[test]
144    fn center_overlay_draws_scrim_and_content() {
145        // A Center overlay draws a full-screen scrim + the content on top.
146        let portal: OverlayPortal<()> =
147            OverlayPortal::new(Container::<()>::new().width(20.0).height(20.0)).overlay(
148                Container::<()>::new()
149                    .width(100.0)
150                    .height(60.0)
151                    .color(frus_core::Color::WHITE),
152                Placement::Center,
153            );
154        let ui = crate::build_ui(
155            &portal,
156            Size::new(400.0, 300.0),
157            &Runtime::default(),
158            &crate::Theme::default(),
159        );
160        // At least: the scrim (full screen) + the overlay's content.
161        let full_screen =
162            ui.scene().primitives().iter().any(
163                |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width >= 400.0),
164            );
165        assert!(full_screen, "the full-screen scrim must be present");
166    }
167
168    /// **A press on an overlay is the overlay's** (milestone 642): on its box, and — under
169    /// one that a press outside dismisses — anywhere in the window. What the shell does to
170    /// the pages under it, the back gesture, does not start there.
171    #[test]
172    fn an_overlay_says_where_presses_are_its_own() {
173        let anchored = |dismiss: bool| {
174            let portal = OverlayPortal::<()>::new(Container::<()>::new().width(20.0).height(20.0))
175                .overlay(
176                    Container::<()>::new().width(100.0).height(60.0),
177                    Placement::Below,
178                );
179            let portal = if dismiss { portal.dismiss(()) } else { portal };
180            crate::build_ui(
181                &portal,
182                Size::new(400.0, 300.0),
183                &Runtime::default(),
184                &crate::Theme::default(),
185            )
186        };
187        let open = anchored(false);
188        assert!(
189            open.over_overlay(frus_core::Point::new(10.0, 40.0)),
190            "on the overlay's box"
191        );
192        assert!(
193            !open.over_overlay(frus_core::Point::new(300.0, 250.0)),
194            "elsewhere, the pages'"
195        );
196        let barred = anchored(true);
197        assert!(
198            barred.over_overlay(frus_core::Point::new(300.0, 250.0)),
199            "anywhere, under a barrier"
200        );
201    }
202
203    /// An anchored overlay is nudged back inside the window when it overflows an edge —
204    /// that is for a menu opened near the right margin, and it assumes the window is
205    /// showing the anchor. When the anchor has **left** the window the nudge does the
206    /// opposite of its job: it pulls a menu into view that belongs to something nobody
207    /// can see, and leaves a window-wide dismissal barrier behind it that swallows the
208    /// next press anywhere.
209    #[test]
210    fn an_overlay_whose_anchor_left_the_window_goes_with_it() {
211        // A row far wider than the window puts the portal's anchor past the right edge.
212        let row: crate::Flex<()> = crate::Flex::row()
213            .child(Container::<()>::new().width(900.0).height(20.0))
214            .child(
215                OverlayPortal::new(Container::<()>::new().width(20.0).height(20.0))
216                    .overlay(
217                        Container::<()>::new()
218                            .width(120.0)
219                            .height(40.0)
220                            .color(frus_core::Color::WHITE),
221                        Placement::Below,
222                    )
223                    .dismiss(()),
224            );
225        let ui = crate::build_ui(
226            &row,
227            Size::new(400.0, 300.0),
228            &Runtime::default(),
229            &crate::Theme::default(),
230        );
231        // The overlay's own 120x40 panel must not have been dragged back on screen.
232        let inside = ui.scene().primitives().iter().any(|p| {
233            matches!(p, frus_core::Primitive::Rect { rect, .. }
234                if (rect.width - 120.0).abs() < 0.5 && rect.x < 400.0)
235        });
236        assert!(!inside, "the overlay was pulled back into the window");
237        // And no window-wide dismissal barrier was left behind — `top_dismiss` is what
238        // Escape and an outside press both resolve through.
239        assert!(
240            ui.top_dismiss().is_none(),
241            "an invisible barrier would eat the next press"
242        );
243    }
244}