Skip to main content

qframe/widgets/
popover.rs

1//! Popovers: content that opens as a layer next to the widget it belongs to.
2
3use std::time::Duration;
4
5use super::placement::{self, Placement};
6use crate::event::Event;
7use crate::geometry::{Rect, Size};
8use crate::keymap::Key;
9use crate::motion::Easing;
10use crate::widget::{Axis, EventCx, Flex, Length, MeasureCx, Node, NodeMut, PaintCx, View, Widget, WidgetId};
11
12type Part<'a, Msg> = Box<dyn FnOnce(&mut View<'_, Msg>) + 'a>;
13
14/// Content shown as a layer next to an anchor, such as a filter panel under a button.
15///
16/// The application owns whether it is open: pass it to [`Popover::new`] and close it when the
17/// [`on_dismiss`](Popover::on_dismiss) message arrives. The layer sits below the anchor, flips
18/// above (or to the other side) when there is no room and never leaves the screen. It unfolds
19/// from the anchor over the theme's `motion.enter`. Esc or a press outside the anchor and the
20/// layer sends the dismiss message, and the press still reaches what it landed on, so one press
21/// on another button closes the popover and presses that button. A press on the widget that
22/// opened the popover only dismisses it, even when that widget is outside the anchor.
23///
24/// Style keys: `popover` (`bg`, `padding`).
25///
26/// ```
27/// use qframe::prelude::*;
28/// use qframe::widgets::Popover;
29///
30/// struct Filters { open: bool }
31///
32/// #[derive(Clone)]
33/// enum Msg { Toggle, Close }
34///
35/// impl App for Filters {
36///     type Msg = Msg;
37///     fn update(&mut self, msg: Msg) -> Command<Msg> {
38///         self.open = matches!(msg, Msg::Toggle) && !self.open;
39///         Command::none()
40///     }
41///     fn view(&self, ui: &mut View<'_, Msg>) {
42///         Popover::new(self.open)
43///             .on_dismiss(Msg::Close)
44///             .anchor(|ui| {
45///                 ui.add(Button::new("Filters").on_press(Msg::Toggle));
46///             })
47///             .content(|ui| {
48///                 ui.add(Text::new("Only running"));
49///             })
50///             .show(ui);
51///     }
52/// }
53///
54/// let mut app = Harness::new(Filters { open: false }, 30, 6);
55/// app.click_text("Filters").advance(std::time::Duration::from_millis(200));
56/// assert!(app.screen().contains("Only running"));
57/// app.press("esc");
58/// assert!(!app.screen().contains("Only running"));
59/// ```
60pub struct Popover<'a, Msg> {
61    open: bool,
62    placement: Placement,
63    focus_inside: bool,
64    on_dismiss: Option<Msg>,
65    anchor: Option<Part<'a, Msg>>,
66    content: Option<Part<'a, Msg>>,
67}
68
69impl<'a, Msg: Clone + 'static> Popover<'a, Msg> {
70    /// A popover that shows its content while `open`.
71    #[must_use]
72    pub fn new(open: bool) -> Self {
73        Self { open, placement: Placement::Below, focus_inside: false, on_dismiss: None, anchor: None, content: None }
74    }
75
76    /// The widgets the layer belongs to, usually a button that toggles it.
77    #[must_use]
78    pub fn anchor(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
79        self.anchor = Some(Box::new(build));
80        self
81    }
82
83    /// The widgets shown in the layer.
84    #[must_use]
85    pub fn content(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
86        self.content = Some(Box::new(build));
87        self
88    }
89
90    /// The preferred side of the anchor; [`Placement::Below`] by default.
91    #[must_use]
92    pub fn placement(mut self, placement: Placement) -> Self {
93        self.placement = placement;
94        self
95    }
96
97    /// Moves keyboard focus to the first focusable widget in the layer when it opens, and back
98    /// to where it was when it closes.
99    #[must_use]
100    pub fn focus_inside(mut self, focus_inside: bool) -> Self {
101        self.focus_inside = focus_inside;
102        self
103    }
104
105    /// Message sent on Esc or a press outside; the application usually closes the popover.
106    #[must_use]
107    pub fn on_dismiss(mut self, message: Msg) -> Self {
108        self.on_dismiss = Some(message);
109        self
110    }
111
112    /// Adds the popover to `ui`. The returned node sizes the anchor.
113    pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
114        let build = |part: Option<Part<'a, Msg>>, index: usize| {
115            let mut children = Vec::new();
116            if let Some(part) = part {
117                part(&mut ui.nested(&mut children));
118            }
119            Node::new(Flex::new(Axis::Column, children), index)
120        };
121        let mut anchor = build(self.anchor, ANCHOR);
122        anchor.layout.width = Length::Fill(1);
123        anchor.layout.height = Length::Fill(1);
124        let content = build(self.content, CONTENT);
125        ui.add(Layer {
126            parts: [anchor, content],
127            open: self.open,
128            placement: self.placement,
129            focus_inside: self.focus_inside,
130            on_dismiss: self.on_dismiss,
131        })
132    }
133}
134
135const ANCHOR: usize = 0;
136const CONTENT: usize = 1;
137
138struct Layer<Msg> {
139    parts: [Node<Msg>; 2],
140    open: bool,
141    placement: Placement,
142    focus_inside: bool,
143    on_dismiss: Option<Msg>,
144}
145
146#[derive(Debug, Default)]
147struct PopoverMemory {
148    was_open: bool,
149    opened_at: Duration,
150    just_opened: bool,
151    focus_before: Option<WidgetId>,
152    focus_was_inside: bool,
153}
154
155impl<Msg: Clone + 'static> Widget<Msg> for Layer<Msg> {
156    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
157        cx.measure_child(&self.parts[ANCHOR], available)
158    }
159
160    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
161        cx.paint_child(&self.parts[ANCHOR], area);
162        let now = cx.now();
163        let focused = cx.focused();
164        let memory = cx.memory::<PopoverMemory>();
165        memory.just_opened = self.open && !memory.was_open;
166        if memory.just_opened {
167            memory.opened_at = now;
168            memory.focus_before = focused;
169        }
170        let closed_with_focus = !self.open && memory.was_open && memory.focus_was_inside;
171        let give_back = memory.focus_before.take_if(|_| closed_with_focus);
172        memory.was_open = self.open;
173        if !self.open {
174            memory.focus_was_inside = false;
175        }
176        if let Some(previous) = give_back.filter(|_| self.focus_inside) {
177            cx.request_focus(previous);
178        }
179        if self.open {
180            cx.request_overlay(area);
181            cx.register_dismissable();
182        }
183    }
184
185    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
186        let style = cx.style("popover", None, &[]);
187        let padding = style.padding();
188        let background = style.text().bg.unwrap_or_else(|| cx.color("overlay"));
189        let screen = cx.clip();
190        let available = Size::new(
191            screen.width.saturating_sub(padding.horizontal()),
192            screen.height.saturating_sub(padding.vertical()),
193        );
194        let content = cx.measure_child(&self.parts[CONTENT], available);
195        let size = Size::new(
196            content.width.saturating_add(padding.horizontal()),
197            content.height.saturating_add(padding.vertical()),
198        );
199        let (full, side) = placement::place(anchor, size, screen, self.placement);
200
201        // The layer unfolds from the anchor's side over `motion.enter`.
202        let (opened_at, just_opened) = {
203            let memory = cx.memory::<PopoverMemory>();
204            (memory.opened_at, memory.just_opened)
205        };
206        let enter = cx.env().theme().motion().enter;
207        let progress = cx.progress_since(opened_at, enter, Easing::EaseOut);
208        let shown = placement::unfold(full, side, progress);
209        cx.register_hit(shown);
210        cx.floating(shown, |cx| {
211            cx.clear(shown, background);
212            cx.with_clip(shown, |cx| cx.paint_child(&self.parts[CONTENT], full.inset(padding)));
213        });
214        let content_id = self.parts[CONTENT].id();
215        if self.focus_inside && just_opened {
216            cx.request_focus_within(content_id);
217        }
218        // Focus on the anchor counts too; giving focus back to it on close changes nothing.
219        let inside = cx.has_focus_within();
220        cx.memory::<PopoverMemory>().focus_was_inside |= inside;
221    }
222
223    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
224        if !self.open {
225            return false;
226        }
227        let dismiss = match event {
228            Event::PointerOutside => true,
229            Event::Key(key) => key.is_plain(Key::Esc),
230            _ => false,
231        };
232        if dismiss && let Some(message) = &self.on_dismiss {
233            cx.emit(message.clone());
234        }
235        dismiss
236    }
237
238    fn children(&self) -> &[Node<Msg>] {
239        &self.parts
240    }
241
242    fn children_mut(&mut self) -> &mut [Node<Msg>] {
243        &mut self.parts
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::runtime::{App, Command, Harness};
251    use crate::widgets::{Button, Text, TextInput};
252
253    struct Demo {
254        open: bool,
255        focus_inside: bool,
256        placement: Placement,
257        name: String,
258        dismissed: u32,
259        other: u32,
260    }
261
262    #[derive(Clone)]
263    enum Msg {
264        Toggle,
265        Dismiss,
266        Name(String),
267        Other,
268    }
269
270    impl App for Demo {
271        type Msg = Msg;
272        fn update(&mut self, msg: Msg) -> Command<Msg> {
273            match msg {
274                Msg::Toggle => self.open = !self.open,
275                Msg::Dismiss => {
276                    self.open = false;
277                    self.dismissed += 1;
278                }
279                Msg::Name(name) => self.name = name,
280                Msg::Other => self.other += 1,
281            }
282            Command::none()
283        }
284        fn view(&self, ui: &mut View<'_, Msg>) {
285            ui.column(|ui| {
286                ui.row(|ui| {
287                    Popover::new(self.open)
288                        .placement(self.placement)
289                        .focus_inside(self.focus_inside)
290                        .on_dismiss(Msg::Dismiss)
291                        .anchor(|ui| {
292                            ui.add(Button::new("Filters").on_press(Msg::Toggle)).id("filters");
293                        })
294                        .content(|ui| {
295                            ui.add(Text::new("Status"));
296                            ui.add(TextInput::new(&self.name).on_change(Msg::Name)).width(Length::Cells(10)).id("name");
297                        })
298                        .show(ui);
299                    ui.add(Button::new("Other").on_press(Msg::Other)).id("other");
300                })
301                .gap(1);
302                ui.add(Text::new("content under the layer")).selectable(true);
303            });
304        }
305    }
306
307    fn demo() -> Demo {
308        Demo {
309            open: false,
310            focus_inside: false,
311            placement: Placement::Below,
312            name: String::new(),
313            dismissed: 0,
314            other: 0,
315        }
316    }
317
318    #[test]
319    fn opens_below_unfolding_on_the_overlay_surface() {
320        let mut h = Harness::new(demo(), 40, 8);
321        h.click_text("Filters");
322        assert!(h.app().open);
323        let first = h.screen();
324        assert!(!first.contains("Status"), "the layer starts folded: {first}");
325        h.advance(Duration::from_millis(200));
326        // The pointer still rests on the anchor button, so it shows its pillar.
327        assert_eq!(h.screen(), "▌ Filters     Other\n              the layer\n  Status\n    ❯\n\n\n\n\n");
328        let overlay = h.env().theme().color("overlay");
329        assert_eq!(h.bg(0, 1), overlay);
330        assert_eq!(h.bg(13, 4), overlay);
331    }
332
333    #[test]
334    fn escape_and_outside_press_dismiss_and_the_press_still_reaches_its_target() {
335        let mut h = Harness::new(Demo { open: true, ..demo() }, 40, 8);
336        h.press("esc");
337        assert_eq!((h.app().open, h.app().dismissed), (false, 1));
338        h.click_text("Filters").advance(Duration::from_millis(200));
339        h.click_text("Other");
340        assert_eq!((h.app().open, h.app().dismissed, h.app().other), (false, 2, 1), "one press closes and acts");
341    }
342
343    #[test]
344    fn a_press_that_closes_the_layer_follows_the_text_selection_rules_of_its_cell() {
345        let mut closed = Harness::new(demo(), 40, 8);
346        closed.drag((16, 1), (30, 1)).press("ctrl+c");
347        assert!(closed.clipboard().is_some_and(|text| text.contains("layer")), "{:?}", closed.clipboard());
348        let mut h = Harness::new(Demo { open: true, ..demo() }, 40, 8);
349        h.advance(Duration::from_millis(200));
350        h.drag((16, 1), (30, 1)).press("ctrl+c");
351        assert_eq!((h.app().open, h.app().dismissed), (false, 1));
352        assert_eq!(h.clipboard(), closed.clipboard(), "the same drag without a layer selects the same");
353    }
354
355    #[test]
356    fn clicking_inside_the_layer_keeps_it_open_and_the_anchor_toggles() {
357        let mut h = Harness::new(Demo { open: true, ..demo() }, 40, 8);
358        h.advance(Duration::from_millis(200));
359        h.click_text("Status");
360        assert!(h.app().open);
361        h.click_text("Filters");
362        assert_eq!((h.app().open, h.app().dismissed), (false, 0));
363    }
364
365    #[test]
366    fn focus_moves_inside_and_comes_back() {
367        let mut h = Harness::new(Demo { focus_inside: true, ..demo() }, 40, 8);
368        h.press("tab");
369        assert!(h.is_focused("filters"));
370        h.press("enter");
371        assert!(h.is_focused("name"));
372        h.type_text("web");
373        assert_eq!(h.app().name, "web");
374        h.press("esc");
375        assert!(!h.app().open);
376        assert!(h.is_focused("filters"));
377    }
378
379    #[test]
380    fn flips_above_near_the_bottom_and_opens_at_once_with_reduced_motion() {
381        struct Bottom(bool);
382        impl App for Bottom {
383            type Msg = ();
384            fn update(&mut self, (): ()) -> Command<()> {
385                Command::none()
386            }
387            fn view(&self, ui: &mut View<'_, ()>) {
388                ui.column(|ui| {
389                    ui.spacer();
390                    Popover::new(self.0)
391                        .anchor(|ui| {
392                            ui.add(Text::new("anchor"));
393                        })
394                        .content(|ui| {
395                            ui.add(Text::new("menu"));
396                        })
397                        .show(ui);
398                })
399                .fill();
400            }
401        }
402        let mut h = Harness::new(Bottom(true), 20, 6);
403        h.set_reduced_motion(true);
404        assert_eq!(h.screen(), "\n\n\n  menu\n\nanchor\n");
405    }
406
407    #[test]
408    fn side_placement_sits_to_the_right() {
409        let mut h = Harness::new(Demo { open: true, placement: Placement::Right, ..demo() }, 60, 8);
410        h.advance(Duration::from_millis(200));
411        let screen = h.screen();
412        assert!(screen.lines().nth(1).is_some_and(|line| line.starts_with("content und  Status")), "{screen}");
413        assert_eq!(h.bg(11, 0), h.env().theme().color("overlay"));
414    }
415}