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/// The layer is as wide as its content, and [`match_anchor_width`](Popover::match_anchor_width)
25/// makes it as wide as the anchor instead: a list under a text field then opens from the field's
26/// left edge to its right one.
27///
28/// Style keys: `popover` (`bg`, `padding`).
29///
30/// ```
31/// use qframe::prelude::*;
32/// use qframe::widgets::Popover;
33///
34/// struct Filters { open: bool }
35///
36/// #[derive(Clone)]
37/// enum Msg { Toggle, Close }
38///
39/// impl App for Filters {
40///     type Msg = Msg;
41///     fn update(&mut self, msg: Msg) -> Command<Msg> {
42///         self.open = matches!(msg, Msg::Toggle) && !self.open;
43///         Command::none()
44///     }
45///     fn view(&self, ui: &mut View<'_, Msg>) {
46///         Popover::new(self.open)
47///             .on_dismiss(Msg::Close)
48///             .anchor(|ui| {
49///                 ui.add(Button::new("Filters").on_press(Msg::Toggle));
50///             })
51///             .content(|ui| {
52///                 ui.add(Text::new("Only running"));
53///             })
54///             .show(ui);
55///     }
56/// }
57///
58/// let mut app = Harness::new(Filters { open: false }, 30, 6);
59/// app.click_text("Filters").advance(std::time::Duration::from_millis(200));
60/// assert!(app.screen().contains("Only running"));
61/// app.press("esc");
62/// assert!(!app.screen().contains("Only running"));
63/// ```
64pub struct Popover<'a, Msg> {
65    open: bool,
66    placement: Placement,
67    focus_inside: bool,
68    match_anchor_width: bool,
69    on_dismiss: Option<Msg>,
70    anchor: Option<Part<'a, Msg>>,
71    content: Option<Part<'a, Msg>>,
72}
73
74impl<'a, Msg: Clone + 'static> Popover<'a, Msg> {
75    /// A popover that shows its content while `open`.
76    #[must_use]
77    pub fn new(open: bool) -> Self {
78        Self {
79            open,
80            placement: Placement::Below,
81            focus_inside: false,
82            match_anchor_width: false,
83            on_dismiss: None,
84            anchor: None,
85            content: None,
86        }
87    }
88
89    /// The widgets the layer belongs to, usually a button that toggles it.
90    #[must_use]
91    pub fn anchor(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
92        self.anchor = Some(Box::new(build));
93        self
94    }
95
96    /// The widgets shown in the layer.
97    #[must_use]
98    pub fn content(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
99        self.content = Some(Box::new(build));
100        self
101    }
102
103    /// The preferred side of the anchor; [`Placement::Below`] by default.
104    #[must_use]
105    pub fn placement(mut self, placement: Placement) -> Self {
106        self.placement = placement;
107        self
108    }
109
110    /// Moves keyboard focus to the first focusable widget in the layer when it opens, and back
111    /// to where it was when it closes.
112    #[must_use]
113    pub fn focus_inside(mut self, focus_inside: bool) -> Self {
114        self.focus_inside = focus_inside;
115        self
116    }
117
118    /// Opens the content exactly as wide as the anchor instead of as wide as the content, cut to
119    /// the screen: a list under a text field then opens from the field's left edge to its right
120    /// one, and a line longer than the field is cut there rather than widening the layer. The
121    /// content is measured at that width, so it lays itself out to fit.
122    #[must_use]
123    pub fn match_anchor_width(mut self, match_anchor_width: bool) -> Self {
124        self.match_anchor_width = match_anchor_width;
125        self
126    }
127
128    /// Message sent on Esc or a press outside; the application usually closes the popover.
129    #[must_use]
130    pub fn on_dismiss(mut self, message: Msg) -> Self {
131        self.on_dismiss = Some(message);
132        self
133    }
134
135    /// Adds the popover to `ui`. The returned node sizes the anchor.
136    pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
137        let build = |part: Option<Part<'a, Msg>>, index: usize| {
138            let mut children = Vec::new();
139            if let Some(part) = part {
140                part(&mut ui.nested(&mut children));
141            }
142            Node::new(Flex::new(Axis::Column, children), index)
143        };
144        let mut anchor = build(self.anchor, ANCHOR);
145        anchor.layout.width = Length::Fill(1);
146        anchor.layout.height = Length::Fill(1);
147        let content = build(self.content, CONTENT);
148        ui.add(Layer {
149            parts: [anchor, content],
150            open: self.open,
151            placement: self.placement,
152            focus_inside: self.focus_inside,
153            match_anchor_width: self.match_anchor_width,
154            on_dismiss: self.on_dismiss,
155        })
156    }
157}
158
159const ANCHOR: usize = 0;
160const CONTENT: usize = 1;
161
162struct Layer<Msg> {
163    parts: [Node<Msg>; 2],
164    open: bool,
165    placement: Placement,
166    focus_inside: bool,
167    match_anchor_width: bool,
168    on_dismiss: Option<Msg>,
169}
170
171#[derive(Debug, Default)]
172struct PopoverMemory {
173    was_open: bool,
174    opened_at: Duration,
175    just_opened: bool,
176    focus_before: Option<WidgetId>,
177    focus_was_inside: bool,
178}
179
180impl<Msg: Clone + 'static> Widget<Msg> for Layer<Msg> {
181    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
182        cx.measure_child(&self.parts[ANCHOR], available)
183    }
184
185    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
186        cx.paint_child(&self.parts[ANCHOR], area);
187        let now = cx.now();
188        let focused = cx.focused();
189        let memory = cx.memory::<PopoverMemory>();
190        memory.just_opened = self.open && !memory.was_open;
191        if memory.just_opened {
192            memory.opened_at = now;
193            memory.focus_before = focused;
194        }
195        let closed_with_focus = !self.open && memory.was_open && memory.focus_was_inside;
196        let give_back = memory.focus_before.take_if(|_| closed_with_focus);
197        memory.was_open = self.open;
198        if !self.open {
199            memory.focus_was_inside = false;
200        }
201        if let Some(previous) = give_back.filter(|_| self.focus_inside) {
202            cx.request_focus(previous);
203        }
204        if self.open {
205            cx.request_overlay(area);
206            cx.register_dismissable();
207        }
208    }
209
210    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
211        let style = cx.style("popover", None, &[]);
212        let padding = style.padding();
213        let background = style.text().bg.unwrap_or_else(|| cx.color("overlay"));
214        let screen = cx.clip();
215        let available = Size::new(
216            screen.width.saturating_sub(padding.horizontal()),
217            screen.height.saturating_sub(padding.vertical()),
218        );
219        let matched = self.match_anchor_width.then(|| placement::anchor_width(anchor, screen));
220        // A layer as wide as its anchor measures its content at that width, so the content lays
221        // itself out to fit; one as wide as its content measures it with the whole screen.
222        let inner = match matched {
223            Some(width) => Size::new(width.saturating_sub(padding.horizontal()), available.height),
224            None => available,
225        };
226        let content = cx.measure_child(&self.parts[CONTENT], inner);
227        let size = Size::new(
228            matched.unwrap_or_else(|| content.width.saturating_add(padding.horizontal())),
229            content.height.saturating_add(padding.vertical()),
230        );
231        let (full, side) = placement::place(anchor, size, screen, self.placement);
232
233        // The layer unfolds from the anchor's side over `motion.enter`.
234        let (opened_at, just_opened) = {
235            let memory = cx.memory::<PopoverMemory>();
236            (memory.opened_at, memory.just_opened)
237        };
238        let enter = cx.env().theme().motion().enter;
239        let progress = cx.progress_since(opened_at, enter, Easing::EaseOut);
240        let shown = placement::unfold(full, side, progress);
241        cx.register_hit(shown);
242        cx.floating(shown, |cx| {
243            cx.clear(shown, background);
244            cx.with_clip(shown, |cx| cx.paint_child(&self.parts[CONTENT], full.inset(padding)));
245        });
246        let content_id = self.parts[CONTENT].id();
247        if self.focus_inside && just_opened {
248            cx.request_focus_within(content_id);
249        }
250        // Focus on the anchor counts too; giving focus back to it on close changes nothing.
251        let inside = cx.has_focus_within();
252        cx.memory::<PopoverMemory>().focus_was_inside |= inside;
253    }
254
255    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
256        if !self.open {
257            return false;
258        }
259        let dismiss = match event {
260            Event::PointerOutside => true,
261            Event::Key(key) => key.is_plain(Key::Esc),
262            _ => false,
263        };
264        if dismiss && let Some(message) = &self.on_dismiss {
265            cx.emit(message.clone());
266        }
267        dismiss
268    }
269
270    fn children(&self) -> &[Node<Msg>] {
271        &self.parts
272    }
273
274    fn children_mut(&mut self) -> &mut [Node<Msg>] {
275        &mut self.parts
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::runtime::{App, Command, Harness};
283    use crate::widgets::{Button, Text, TextInput};
284
285    struct Demo {
286        open: bool,
287        focus_inside: bool,
288        placement: Placement,
289        name: String,
290        dismissed: u32,
291        other: u32,
292    }
293
294    #[derive(Clone)]
295    enum Msg {
296        Toggle,
297        Dismiss,
298        Name(String),
299        Other,
300    }
301
302    impl App for Demo {
303        type Msg = Msg;
304        fn update(&mut self, msg: Msg) -> Command<Msg> {
305            match msg {
306                Msg::Toggle => self.open = !self.open,
307                Msg::Dismiss => {
308                    self.open = false;
309                    self.dismissed += 1;
310                }
311                Msg::Name(name) => self.name = name,
312                Msg::Other => self.other += 1,
313            }
314            Command::none()
315        }
316        fn view(&self, ui: &mut View<'_, Msg>) {
317            ui.column(|ui| {
318                ui.row(|ui| {
319                    Popover::new(self.open)
320                        .placement(self.placement)
321                        .focus_inside(self.focus_inside)
322                        .on_dismiss(Msg::Dismiss)
323                        .anchor(|ui| {
324                            ui.add(Button::new("Filters").on_press(Msg::Toggle)).id("filters");
325                        })
326                        .content(|ui| {
327                            ui.add(Text::new("Status"));
328                            ui.add(TextInput::new(&self.name).on_change(Msg::Name)).width(Length::Cells(10)).id("name");
329                        })
330                        .show(ui);
331                    ui.add(Button::new("Other").on_press(Msg::Other)).id("other");
332                })
333                .gap(1);
334                ui.add(Text::new("content under the layer")).selectable(true);
335            });
336        }
337    }
338
339    fn demo() -> Demo {
340        Demo {
341            open: false,
342            focus_inside: false,
343            placement: Placement::Below,
344            name: String::new(),
345            dismissed: 0,
346            other: 0,
347        }
348    }
349
350    #[test]
351    fn opens_below_unfolding_on_the_overlay_surface() {
352        let mut h = Harness::new(demo(), 40, 8);
353        h.click_text("Filters");
354        assert!(h.app().open);
355        let first = h.screen();
356        assert!(!first.contains("Status"), "the layer starts folded: {first}");
357        h.advance(Duration::from_millis(200));
358        // The pointer still rests on the anchor button, so it shows its pillar.
359        assert_eq!(h.screen(), "▌ Filters     Other\n              the layer\n  Status\n    ❯\n\n\n\n\n");
360        let overlay = h.env().theme().color("overlay");
361        assert_eq!(h.bg(0, 1), overlay);
362        assert_eq!(h.bg(13, 4), overlay);
363    }
364
365    #[test]
366    fn escape_and_outside_press_dismiss_and_the_press_still_reaches_its_target() {
367        let mut h = Harness::new(Demo { open: true, ..demo() }, 40, 8);
368        h.press("esc");
369        assert_eq!((h.app().open, h.app().dismissed), (false, 1));
370        h.click_text("Filters").advance(Duration::from_millis(200));
371        h.click_text("Other");
372        assert_eq!((h.app().open, h.app().dismissed, h.app().other), (false, 2, 1), "one press closes and acts");
373    }
374
375    #[test]
376    fn a_press_that_closes_the_layer_follows_the_text_selection_rules_of_its_cell() {
377        let mut closed = Harness::new(demo(), 40, 8);
378        closed.drag((16, 1), (30, 1)).press("ctrl+c");
379        assert!(closed.clipboard().is_some_and(|text| text.contains("layer")), "{:?}", closed.clipboard());
380        let mut h = Harness::new(Demo { open: true, ..demo() }, 40, 8);
381        h.advance(Duration::from_millis(200));
382        h.drag((16, 1), (30, 1)).press("ctrl+c");
383        assert_eq!((h.app().open, h.app().dismissed), (false, 1));
384        assert_eq!(h.clipboard(), closed.clipboard(), "the same drag without a layer selects the same");
385    }
386
387    #[test]
388    fn clicking_inside_the_layer_keeps_it_open_and_the_anchor_toggles() {
389        let mut h = Harness::new(Demo { open: true, ..demo() }, 40, 8);
390        h.advance(Duration::from_millis(200));
391        h.click_text("Status");
392        assert!(h.app().open);
393        h.click_text("Filters");
394        assert_eq!((h.app().open, h.app().dismissed), (false, 0));
395    }
396
397    #[test]
398    fn focus_moves_inside_and_comes_back() {
399        let mut h = Harness::new(Demo { focus_inside: true, ..demo() }, 40, 8);
400        h.press("tab");
401        assert!(h.is_focused("filters"));
402        h.press("enter");
403        assert!(h.is_focused("name"));
404        h.type_text("web");
405        assert_eq!(h.app().name, "web");
406        h.press("esc");
407        assert!(!h.app().open);
408        assert!(h.is_focused("filters"));
409    }
410
411    #[test]
412    fn flips_above_near_the_bottom_and_opens_at_once_with_reduced_motion() {
413        struct Bottom(bool);
414        impl App for Bottom {
415            type Msg = ();
416            fn update(&mut self, (): ()) -> Command<()> {
417                Command::none()
418            }
419            fn view(&self, ui: &mut View<'_, ()>) {
420                ui.column(|ui| {
421                    ui.spacer();
422                    Popover::new(self.0)
423                        .anchor(|ui| {
424                            ui.add(Text::new("anchor"));
425                        })
426                        .content(|ui| {
427                            ui.add(Text::new("menu"));
428                        })
429                        .show(ui);
430                })
431                .fill();
432            }
433        }
434        let mut h = Harness::new(Bottom(true), 20, 6);
435        h.set_reduced_motion(true);
436        assert_eq!(h.screen(), "\n\n\n  menu\n\nanchor\n");
437    }
438
439    #[test]
440    fn side_placement_sits_to_the_right() {
441        let mut h = Harness::new(Demo { open: true, placement: Placement::Right, ..demo() }, 60, 8);
442        h.advance(Duration::from_millis(200));
443        let screen = h.screen();
444        assert!(screen.lines().nth(1).is_some_and(|line| line.starts_with("content und  Status")), "{screen}");
445        assert_eq!(h.bg(11, 0), h.env().theme().color("overlay"));
446    }
447
448    /// A layer over an anchor `width` cells wide, whose content wants to be wider than that.
449    struct Anchored {
450        width: u16,
451        matched: bool,
452    }
453
454    impl App for Anchored {
455        type Msg = ();
456        fn update(&mut self, (): ()) -> Command<()> {
457            Command::none()
458        }
459        fn view(&self, ui: &mut View<'_, ()>) {
460            Popover::new(true)
461                .match_anchor_width(self.matched)
462                .anchor(|ui| {
463                    ui.add(Text::new("anchor"));
464                })
465                .content(|ui| {
466                    ui.add(Text::new("a line far wider than its anchor"));
467                })
468                .show(ui)
469                .width(Length::Cells(self.width));
470        }
471    }
472
473    /// The first and last column of row `y` the layer covers, read from the grounds: the layer
474    /// paints on the overlay tone and the screen around it is the canvas.
475    fn span(h: &Harness<Anchored>, y: u16) -> Option<(u16, u16)> {
476        let canvas = h.env().theme().color("canvas");
477        let columns: Vec<u16> = (0..40u16).filter(|x| h.bg(*x, y) != canvas).collect();
478        columns.first().zip(columns.last()).map(|(first, last)| (*first, *last))
479    }
480
481    #[test]
482    fn the_layer_is_as_wide_as_its_anchor_whatever_the_content_wants() {
483        for width in [14, 30] {
484            let mut h = Harness::new(Anchored { width, matched: true }, 40, 8);
485            h.set_reduced_motion(true);
486            let screen = h.screen();
487            assert_eq!(span(&h, 1), Some((0, width - 1)), "from the anchor's left edge to its right one:\n{screen}");
488
489            let mut h = Harness::new(Anchored { width, matched: false }, 40, 8);
490            h.set_reduced_motion(true);
491            let screen = h.screen();
492            assert!(
493                span(&h, 1).is_some_and(|(_, last)| last > width - 1),
494                "without the option the content decides:\n{screen}"
495            );
496        }
497        let mut h = Harness::new(Anchored { width: 50, matched: true }, 40, 8);
498        h.set_reduced_motion(true);
499        assert_eq!(span(&h, 1), Some((0, 39)), "an anchor wider than the screen is cut to it:\n{}", h.screen());
500    }
501}