Skip to main content

frus_widgets/
popover.rs

1//! [`MenuAnchor`]: a floating panel with **free content**, anchored and controlled, that
2//! closes on an outside click. It generalises [`crate::PopupMenuButton`] to any content,
3//! and floats that content on the same panel.
4
5use std::rc::Rc;
6
7use frus_core::{BorderRadius, Color, Insets, Rect, Scene, ShapeBorder};
8use frus_layout::{FlexDirection, Style};
9
10use crate::interaction::Status;
11use crate::menu::{Panel, PanelKind, PanelStyle};
12use crate::portal::Placement;
13use crate::theme::Theme;
14use crate::transparent::Shared;
15use crate::widget::Widget;
16
17/// A popover: an anchor plus an optional floating panel.
18///
19/// **The content floats on a panel**: one surface in `surface_container`, the menu's
20/// corner, eight pixels of room above and below, and a shadow three high — the reference's
21/// menu defaults, and the panel [`PopupMenuButton`](crate::PopupMenuButton) floats its
22/// rows on. Each of those answers to the builders below, then to
23/// [`MenuTheme`](crate::MenuTheme). Content that brings a surface of its own — a `Card` —
24/// would be a surface on a surface; hand the anchor what goes *on* the panel instead.
25pub struct MenuAnchor<Msg = crate::callback::Callback> {
26    open: bool,
27    /// `[anchor]`, or `[anchor, panel]` when open with content.
28    children: Vec<Box<dyn Widget<Msg>>>,
29    /// The caller's content, shared so the panel can be built again when a builder is
30    /// written after [`content`](Self::content).
31    content: Option<Rc<dyn Widget<Msg>>>,
32    look: PanelStyle,
33    on_dismiss: Option<Msg>,
34}
35
36impl<Msg: Clone + 'static> MenuAnchor<Msg> {
37    /// Creates a popover around an anchor. When `open`, the content floats;
38    /// `on_dismiss` is emitted on a click **outside** the popover.
39    pub fn new(anchor: impl Widget<Msg> + 'static, open: bool, on_dismiss: Msg) -> Self {
40        Self {
41            open,
42            children: vec![Box::new(anchor)],
43            content: None,
44            look: PanelStyle::default(),
45            on_dismiss: if open { Some(on_dismiss) } else { None },
46        }
47    }
48
49    /// Sets the floating content, which is shown only when open.
50    pub fn content(mut self, content: impl Widget<Msg> + 'static) -> Self {
51        if self.open {
52            let content: Rc<dyn Widget<Msg>> = Rc::new(content);
53            self.content = Some(content);
54            self.rebuild();
55        }
56        self
57    }
58
59    /// **The panel's surface**, over the theme's and the reference's `surface_container`.
60    #[must_use]
61    pub fn background(mut self, color: Color) -> Self {
62        self.look.background = Some(color);
63        self.rebuild();
64        self
65    }
66
67    /// **What shape the panel is**, over the theme's and the framework's corner.
68    #[must_use]
69    pub fn shape(mut self, shape: ShapeBorder) -> Self {
70        self.look.shape = Some(shape);
71        self.rebuild();
72        self
73    }
74
75    /// The shorthand for a rounded rectangle — `radius(4.0)` is the reference's own menu
76    /// corner. See [`PopupMenuButton::radius`](crate::PopupMenuButton::radius) for why it
77    /// is not the default.
78    #[must_use]
79    pub fn radius(self, radius: impl Into<BorderRadius>) -> Self {
80        self.shape(ShapeBorder::rounded(radius.into()))
81    }
82
83    /// **How far off the page the panel sits**, in pixels. Three by default.
84    #[must_use]
85    pub fn elevation(mut self, elevation: f32) -> Self {
86        self.look.elevation = Some(elevation);
87        self.rebuild();
88        self
89    }
90
91    /// The colour of the panel's shadow, over the theme's and the scheme's shadow at 30 %.
92    /// [`Color::TRANSPARENT`] casts none.
93    #[must_use]
94    pub fn shadow_color(mut self, color: Color) -> Self {
95        self.look.shadow_color = Some(color);
96        self.rebuild();
97        self
98    }
99
100    /// The room kept **around** the content, inside the panel. Eight above and below by
101    /// default, and nothing either side.
102    #[must_use]
103    pub fn menu_padding(mut self, padding: Insets) -> Self {
104        self.look.padding = Some(padding);
105        self.rebuild();
106        self
107    }
108
109    /// (Re)builds the floating panel (child 1) around the content.
110    fn rebuild(&mut self) {
111        self.children.truncate(1);
112        if let (true, Some(content)) = (self.open, &self.content) {
113            self.children.push(Box::new(Panel::new(
114                PanelKind::Menu,
115                self.look,
116                vec![Box::new(Shared::new(content.clone()))],
117            )));
118        }
119    }
120}
121
122impl<Msg: Clone> Widget<Msg> for MenuAnchor<Msg> {
123    fn style(&self) -> Style {
124        Style {
125            flex_direction: FlexDirection::Column,
126            ..Default::default()
127        }
128    }
129
130    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
131        &self.children
132    }
133
134    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
135
136    fn on_click(&self) -> Option<Msg> {
137        None
138    }
139
140    fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
141        self.children
142            .get(1)
143            .map(|content| (content.as_ref(), Placement::Below))
144    }
145
146    fn overlay_dismiss(&self) -> Option<Msg> {
147        self.on_dismiss.clone()
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::menu::probe::{blurred, colour_of, crisp, height_of};
155    use crate::{build_ui, Container, Runtime, Size, Text};
156
157    #[derive(Clone, Debug, PartialEq)]
158    enum Msg {
159        Close,
160    }
161
162    fn anchor() -> Container<Msg> {
163        Container::<Msg>::new().width(40.0).height(30.0)
164    }
165
166    const MARK: Color = Color::rgb(0.9, 0.1, 0.1);
167
168    /// An open anchor floating a 100 × 50 box in a colour of its own.
169    fn open() -> MenuAnchor<Msg> {
170        MenuAnchor::new(anchor(), true, Msg::Close).content(
171            Container::<Msg>::new()
172                .width(100.0)
173                .height(50.0)
174                .color(MARK),
175        )
176    }
177
178    fn frame(menu: &MenuAnchor<Msg>, theme: &Theme) -> crate::Ui<Msg> {
179        build_ui(menu, Size::new(400.0, 400.0), &Runtime::default(), theme)
180    }
181
182    #[test]
183    fn closed_has_no_overlay() {
184        let p = MenuAnchor::new(anchor(), false, Msg::Close).content(Text::new("hi"));
185        assert!(Widget::<Msg>::overlay(&p).is_none());
186    }
187
188    #[test]
189    fn open_floats_content_and_dismisses() {
190        let p = MenuAnchor::new(anchor(), true, Msg::Close).content(Text::new("hi"));
191        assert!(Widget::<Msg>::overlay(&p).is_some());
192        assert_eq!(Widget::<Msg>::overlay_dismiss(&p), Some(Msg::Close));
193    }
194
195    /// **The content floats on a panel.** It floated bare: no surface, no corner, no room
196    /// around it, and so nothing a shadow could be the shadow of, where the reference's
197    /// anchor always wraps what it is given in one.
198    ///
199    /// One rectangle in `surface_container`, drawn before the content, as wide as the
200    /// content and eight taller above and below, rounded, with no outline — and under it
201    /// one shadow, three high: a blur of twenty, dropped six, in the scheme's shadow at
202    /// 30 %.
203    #[test]
204    fn the_content_floats_on_one_panel_with_a_shadow_three_high() {
205        let theme = Theme::default();
206        let ui = frame(&open(), &theme);
207        let painted = crisp(ui.scene());
208        let panels: Vec<_> = painted
209            .iter()
210            .filter(|r| r.color == theme.scheme.surface_container)
211            .collect();
212        assert_eq!(panels.len(), 1, "one surface: {painted:#?}");
213        let panel = panels[0];
214        let content = painted
215            .iter()
216            .position(|r| r.color == MARK)
217            .expect("the content is drawn");
218        let behind = painted
219            .iter()
220            .position(|r| r.color == theme.scheme.surface_container)
221            .unwrap();
222        assert!(behind < content, "the panel is painted behind the content");
223        let content = painted[content];
224        assert_eq!(
225            panel.rect.width, content.rect.width,
226            "as wide as its content"
227        );
228        assert_eq!(
229            panel.rect.height,
230            content.rect.height + 16.0,
231            "and eight more each way"
232        );
233        assert_eq!(content.rect.y, panel.rect.y + 8.0);
234        assert!(panel.radius != BorderRadius::ZERO, "rounded");
235        assert!(painted.iter().all(|r| r.border_width == 0.0), "no outline");
236
237        let shadows = blurred(ui.scene());
238        assert_eq!(
239            shadows.len(),
240            3,
241            "one surface's three shadows: {shadows:#?}"
242        );
243        assert_eq!(height_of(ui.scene()), 3.0, "three high");
244        assert_eq!(
245            shadows[1].rect.y + shadows[1].rect.height * 0.5,
246            panel.rect.y + panel.rect.height * 0.5 + 3.0,
247            "the second shadow drops by the height"
248        );
249        assert_eq!(colour_of(ui.scene()), Some(theme.scheme.shadow));
250    }
251
252    /// **Every word about the panel is the caller's to say**, over the theme's, and the
253    /// theme's over the framework's: surface, corner, height, shadow colour and room.
254    #[test]
255    fn the_panel_answers_to_its_theme_and_to_its_caller() {
256        let mut theme = Theme::default();
257        let (surface, shade) = (Color::rgb(0.2, 0.4, 0.6), Color::rgba(0.0, 0.0, 0.5, 0.5));
258        theme.widgets.menu.background = Some(surface);
259        theme.widgets.menu.radius = Some(3.0);
260        theme.widgets.menu.elevation = Some(1.0);
261        theme.widgets.menu.shadow_color = Some(shade);
262        theme.widgets.menu.padding = Some(Insets::new(4.0, 4.0, 4.0, 4.0));
263
264        let ui = frame(&open(), &theme);
265        let panel = *crisp(ui.scene())
266            .iter()
267            .find(|r| r.color == surface)
268            .expect("the theme's surface");
269        assert_eq!(
270            panel.radius,
271            BorderRadius::uniform(3.0),
272            "the theme's corner"
273        );
274        assert_eq!(
275            (panel.rect.width, panel.rect.height),
276            (108.0, 58.0),
277            "its room"
278        );
279        assert_eq!(
280            (height_of(ui.scene()), colour_of(ui.scene())),
281            (1.0, Some(shade)),
282            "its height and colour"
283        );
284
285        let (own, own_shade) = (Color::rgb(0.9, 0.9, 0.1), Color::rgba(0.5, 0.0, 0.0, 0.4));
286        let told = open()
287            .background(own)
288            .radius(9.0)
289            .elevation(2.0)
290            .shadow_color(own_shade)
291            .menu_padding(Insets::ZERO);
292        let ui = frame(&told, &theme);
293        let panel = *crisp(ui.scene())
294            .iter()
295            .find(|r| r.color == own)
296            .expect("the caller's surface");
297        assert_eq!(panel.radius, BorderRadius::uniform(9.0));
298        assert_eq!((panel.rect.width, panel.rect.height), (100.0, 50.0));
299        assert_eq!(
300            (height_of(ui.scene()), colour_of(ui.scene())),
301            (2.0, Some(own_shade))
302        );
303    }
304
305    /// **A transparent shadow colour casts nothing**, on the anchor or on its theme — the
306    /// rule of milestone 529 — and a flat panel casts nothing in any colour.
307    #[test]
308    fn a_transparent_shadow_or_a_flat_panel_casts_nothing() {
309        let theme = Theme::default();
310        let clear = open().shadow_color(Color::TRANSPARENT);
311        assert!(blurred(frame(&clear, &theme).scene()).is_empty());
312        assert!(blurred(frame(&open().elevation(0.0), &theme).scene()).is_empty());
313        assert!(
314            crisp(frame(&open().elevation(0.0), &theme).scene())
315                .iter()
316                .all(|r| r.color != theme.scheme.shadow.with_alpha(0.30)),
317            "not even an unblurred box of it"
318        );
319
320        let mut themed = Theme::default();
321        themed.widgets.menu.shadow_color = Some(Color::TRANSPARENT);
322        assert!(blurred(frame(&open(), &themed).scene()).is_empty());
323    }
324
325    /// **The builders can be written in any order**: a look said before the content is the
326    /// look said after it.
327    ///
328    /// Each builder is written **alone** after the content. Written together, the last
329    /// one rebuilds the panel for all of them, and a builder that forgot to rebuild would
330    /// pass behind it — which is how the first version of this test let exactly that
331    /// mutation through.
332    #[test]
333    fn the_builders_can_be_written_in_any_order() {
334        let theme = Theme::default();
335        let colour = Color::rgb(0.1, 0.7, 0.3);
336        type Builder = fn(MenuAnchor<Msg>) -> MenuAnchor<Msg>;
337        let builders: [(&str, Builder); 5] = [
338            ("background", |m| m.background(Color::rgb(0.1, 0.7, 0.3))),
339            ("shape", |m| m.radius(2.0)),
340            ("elevation", |m| m.elevation(5.0)),
341            ("shadow_color", |m| {
342                m.shadow_color(Color::rgba(0.0, 0.5, 0.0, 0.5))
343            }),
344            ("menu_padding", |m| {
345                m.menu_padding(Insets::new(1.0, 2.0, 3.0, 4.0))
346            }),
347        ];
348        for (name, say) in builders {
349            let first = say(MenuAnchor::new(anchor(), true, Msg::Close)).content(
350                Container::<Msg>::new()
351                    .width(100.0)
352                    .height(50.0)
353                    .color(MARK),
354            );
355            let last = say(open());
356            let (before, after) = (frame(&first, &theme), frame(&last, &theme));
357            assert_eq!(crisp(before.scene()), crisp(after.scene()), "{name}");
358            assert_eq!(blurred(before.scene()), blurred(after.scene()), "{name}");
359            let untold = frame(&open(), &theme);
360            assert_ne!(
361                (crisp(after.scene()), blurred(after.scene())),
362                (crisp(untold.scene()), blurred(untold.scene())),
363                "{name} after the content changed something"
364            );
365        }
366        assert!(crisp(frame(&open().background(colour), &theme).scene())
367            .iter()
368            .any(|r| r.color == colour));
369    }
370
371    /// **A press on the panel's own room does not close it**, and one outside still does.
372    #[test]
373    fn a_press_on_the_panel_does_not_close_it() {
374        let ui = frame(&open(), &Theme::default());
375        let press = |x, y| {
376            ui.hit(frus_core::Point::new(x, y))
377                .and_then(|id| ui.msg_for(id))
378        };
379        assert_eq!(press(50.0, 34.0), None, "the room above the content");
380        assert_eq!(press(390.0, 390.0), Some(Msg::Close));
381    }
382}