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}
30
31/// A portal: an **anchor** (in the flow) and an optional floating **overlay**.
32pub struct OverlayPortal<Msg = crate::callback::Callback> {
33    /// `[anchor]` or `[anchor, overlay]`.
34    children: Vec<Box<dyn Widget<Msg>>>,
35    placement: Placement,
36    /// Message emitted when the scrim is clicked (a modal), to close it.
37    on_dismiss: Option<Msg>,
38}
39
40impl<Msg: 'static> OverlayPortal<Msg> {
41    /// Creates a portal around an anchor, which is rendered normally.
42    pub fn new(anchor: impl Widget<Msg> + 'static) -> Self {
43        Self::new_boxed(Box::new(anchor))
44    }
45
46    /// [`Self::new`], for an anchor already boxed — what a widget that *assembles* its
47    /// anchor has, rather than an `impl Widget` a caller typed out.
48    pub fn new_boxed(anchor: Box<dyn Widget<Msg>>) -> Self {
49        Self {
50            children: vec![Box::new(anchor)],
51            placement: Placement::Below,
52            on_dismiss: None,
53        }
54    }
55
56    /// Adds the floating content and its placement.
57    pub fn overlay(self, content: impl Widget<Msg> + 'static, placement: Placement) -> Self {
58        self.overlay_boxed(Box::new(content), placement)
59    }
60
61    /// [`Self::overlay`], for content already boxed.
62    pub fn overlay_boxed(mut self, content: Box<dyn Widget<Msg>>, placement: Placement) -> Self {
63        self.children.truncate(1);
64        self.children.push(Box::new(content));
65        self.placement = placement;
66        self
67    }
68
69    /// Message emitted on a click **outside** the content (on the scrim) — to close.
70    pub fn dismiss(mut self, message: impl Into<Msg>) -> Self {
71        self.on_dismiss = Some(message.into());
72        self
73    }
74}
75
76impl<Msg: Clone> Widget<Msg> for OverlayPortal<Msg> {
77    fn style(&self) -> Style {
78        Style::default()
79    }
80
81    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
82        &self.children
83    }
84
85    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
86
87    fn on_click(&self) -> Option<Msg> {
88        None
89    }
90
91    fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
92        self.children
93            .get(1)
94            .map(|content| (content.as_ref(), self.placement))
95    }
96
97    fn overlay_dismiss(&self) -> Option<Msg> {
98        self.on_dismiss.clone()
99    }
100
101    /// `Escape` closes the portal (if a dismiss message is configured) — consumed
102    /// while bubbling leaf→root when the focus is inside.
103    fn on_key(&self, key: &crate::Key) -> crate::KeyResponse<Msg> {
104        match (key, &self.on_dismiss) {
105            (crate::Key::Escape, Some(message)) => {
106                crate::KeyResponse::Handled(Some(message.clone()))
107            }
108            _ => crate::KeyResponse::Ignored,
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::{Container, Runtime, Size, Text};
117
118    #[test]
119    fn overlay_present_only_when_set() {
120        let bare: OverlayPortal<()> = OverlayPortal::new(Container::<()>::new());
121        assert!(Widget::<()>::overlay(&bare).is_none());
122
123        let with: OverlayPortal<()> =
124            OverlayPortal::new(Container::<()>::new()).overlay(Text::new("tip"), Placement::Center);
125        assert!(Widget::<()>::overlay(&with).is_some());
126    }
127
128    #[test]
129    fn center_overlay_draws_scrim_and_content() {
130        // A Center overlay draws a full-screen scrim + the content on top.
131        let portal: OverlayPortal<()> =
132            OverlayPortal::new(Container::<()>::new().width(20.0).height(20.0)).overlay(
133                Container::<()>::new()
134                    .width(100.0)
135                    .height(60.0)
136                    .color(frus_core::Color::WHITE),
137                Placement::Center,
138            );
139        let ui = crate::build_ui(
140            &portal,
141            Size::new(400.0, 300.0),
142            &Runtime::default(),
143            &crate::Theme::default(),
144        );
145        // At least: the scrim (full screen) + the overlay's content.
146        let full_screen =
147            ui.scene().primitives().iter().any(
148                |p| matches!(p, frus_core::Primitive::Rect { rect, .. } if rect.width >= 400.0),
149            );
150        assert!(full_screen, "the full-screen scrim must be present");
151    }
152
153    /// An anchored overlay is nudged back inside the window when it overflows an edge —
154    /// that is for a menu opened near the right margin, and it assumes the window is
155    /// showing the anchor. When the anchor has **left** the window the nudge does the
156    /// opposite of its job: it pulls a menu into view that belongs to something nobody
157    /// can see, and leaves a window-wide dismissal barrier behind it that swallows the
158    /// next press anywhere.
159    #[test]
160    fn an_overlay_whose_anchor_left_the_window_goes_with_it() {
161        // A row far wider than the window puts the portal's anchor past the right edge.
162        let row: crate::Flex<()> = crate::Flex::row()
163            .child(Container::<()>::new().width(900.0).height(20.0))
164            .child(
165                OverlayPortal::new(Container::<()>::new().width(20.0).height(20.0))
166                    .overlay(
167                        Container::<()>::new()
168                            .width(120.0)
169                            .height(40.0)
170                            .color(frus_core::Color::WHITE),
171                        Placement::Below,
172                    )
173                    .dismiss(()),
174            );
175        let ui = crate::build_ui(
176            &row,
177            Size::new(400.0, 300.0),
178            &Runtime::default(),
179            &crate::Theme::default(),
180        );
181        // The overlay's own 120x40 panel must not have been dragged back on screen.
182        let inside = ui.scene().primitives().iter().any(|p| {
183            matches!(p, frus_core::Primitive::Rect { rect, .. }
184                if (rect.width - 120.0).abs() < 0.5 && rect.x < 400.0)
185        });
186        assert!(!inside, "the overlay was pulled back into the window");
187        // And no window-wide dismissal barrier was left behind — `top_dismiss` is what
188        // Escape and an outside press both resolve through.
189        assert!(
190            ui.top_dismiss().is_none(),
191            "an invisible barrier would eat the next press"
192        );
193    }
194}