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, crisp};
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!(shadows.len(), 1, "one shadow: {shadows:#?}");
239        let shadow = shadows[0];
240        assert_eq!(shadow.blur, 20.0, "three high: four a step from eight");
241        assert_eq!(
242            shadow.rect.y,
243            panel.rect.y + 6.0 - 20.0,
244            "dropped two a step"
245        );
246        assert_eq!(shadow.color, theme.scheme.shadow.with_alpha(0.30));
247    }
248
249    /// **Every word about the panel is the caller's to say**, over the theme's, and the
250    /// theme's over the framework's: surface, corner, height, shadow colour and room.
251    #[test]
252    fn the_panel_answers_to_its_theme_and_to_its_caller() {
253        let mut theme = Theme::default();
254        let (surface, shade) = (Color::rgb(0.2, 0.4, 0.6), Color::rgba(0.0, 0.0, 0.5, 0.5));
255        theme.widgets.menu.background = Some(surface);
256        theme.widgets.menu.radius = Some(3.0);
257        theme.widgets.menu.elevation = Some(1.0);
258        theme.widgets.menu.shadow_color = Some(shade);
259        theme.widgets.menu.padding = Some(Insets::new(4.0, 4.0, 4.0, 4.0));
260
261        let ui = frame(&open(), &theme);
262        let panel = *crisp(ui.scene())
263            .iter()
264            .find(|r| r.color == surface)
265            .expect("the theme's surface");
266        assert_eq!(
267            panel.radius,
268            BorderRadius::uniform(3.0),
269            "the theme's corner"
270        );
271        assert_eq!(
272            (panel.rect.width, panel.rect.height),
273            (108.0, 58.0),
274            "its room"
275        );
276        let shadow = blurred(ui.scene())[0];
277        assert_eq!(
278            (shadow.blur, shadow.color),
279            (12.0, shade),
280            "its height and colour"
281        );
282
283        let (own, own_shade) = (Color::rgb(0.9, 0.9, 0.1), Color::rgba(0.5, 0.0, 0.0, 0.4));
284        let told = open()
285            .background(own)
286            .radius(9.0)
287            .elevation(2.0)
288            .shadow_color(own_shade)
289            .menu_padding(Insets::ZERO);
290        let ui = frame(&told, &theme);
291        let panel = *crisp(ui.scene())
292            .iter()
293            .find(|r| r.color == own)
294            .expect("the caller's surface");
295        assert_eq!(panel.radius, BorderRadius::uniform(9.0));
296        assert_eq!((panel.rect.width, panel.rect.height), (100.0, 50.0));
297        let shadow = blurred(ui.scene())[0];
298        assert_eq!((shadow.blur, shadow.color), (16.0, own_shade));
299    }
300
301    /// **A transparent shadow colour casts nothing**, on the anchor or on its theme — the
302    /// rule of milestone 529 — and a flat panel casts nothing in any colour.
303    #[test]
304    fn a_transparent_shadow_or_a_flat_panel_casts_nothing() {
305        let theme = Theme::default();
306        let clear = open().shadow_color(Color::TRANSPARENT);
307        assert!(blurred(frame(&clear, &theme).scene()).is_empty());
308        assert!(blurred(frame(&open().elevation(0.0), &theme).scene()).is_empty());
309        assert!(
310            crisp(frame(&open().elevation(0.0), &theme).scene())
311                .iter()
312                .all(|r| r.color != theme.scheme.shadow.with_alpha(0.30)),
313            "not even an unblurred box of it"
314        );
315
316        let mut themed = Theme::default();
317        themed.widgets.menu.shadow_color = Some(Color::TRANSPARENT);
318        assert!(blurred(frame(&open(), &themed).scene()).is_empty());
319    }
320
321    /// **The builders can be written in any order**: a look said before the content is the
322    /// look said after it.
323    ///
324    /// Each builder is written **alone** after the content. Written together, the last
325    /// one rebuilds the panel for all of them, and a builder that forgot to rebuild would
326    /// pass behind it — which is how the first version of this test let exactly that
327    /// mutation through.
328    #[test]
329    fn the_builders_can_be_written_in_any_order() {
330        let theme = Theme::default();
331        let colour = Color::rgb(0.1, 0.7, 0.3);
332        type Builder = fn(MenuAnchor<Msg>) -> MenuAnchor<Msg>;
333        let builders: [(&str, Builder); 5] = [
334            ("background", |m| m.background(Color::rgb(0.1, 0.7, 0.3))),
335            ("shape", |m| m.radius(2.0)),
336            ("elevation", |m| m.elevation(5.0)),
337            ("shadow_color", |m| {
338                m.shadow_color(Color::rgba(0.0, 0.5, 0.0, 0.5))
339            }),
340            ("menu_padding", |m| {
341                m.menu_padding(Insets::new(1.0, 2.0, 3.0, 4.0))
342            }),
343        ];
344        for (name, say) in builders {
345            let first = say(MenuAnchor::new(anchor(), true, Msg::Close)).content(
346                Container::<Msg>::new()
347                    .width(100.0)
348                    .height(50.0)
349                    .color(MARK),
350            );
351            let last = say(open());
352            let (before, after) = (frame(&first, &theme), frame(&last, &theme));
353            assert_eq!(crisp(before.scene()), crisp(after.scene()), "{name}");
354            assert_eq!(blurred(before.scene()), blurred(after.scene()), "{name}");
355            let untold = frame(&open(), &theme);
356            assert_ne!(
357                (crisp(after.scene()), blurred(after.scene())),
358                (crisp(untold.scene()), blurred(untold.scene())),
359                "{name} after the content changed something"
360            );
361        }
362        assert!(crisp(frame(&open().background(colour), &theme).scene())
363            .iter()
364            .any(|r| r.color == colour));
365    }
366
367    /// **A press on the panel's own room does not close it**, and one outside still does.
368    #[test]
369    fn a_press_on_the_panel_does_not_close_it() {
370        let ui = frame(&open(), &Theme::default());
371        let press = |x, y| {
372            ui.hit(frus_core::Point::new(x, y))
373                .and_then(|id| ui.msg_for(id))
374        };
375        assert_eq!(press(50.0, 34.0), None, "the room above the content");
376        assert_eq!(press(390.0, 390.0), Some(Msg::Close));
377    }
378}