Skip to main content

iced_cube/overlay/
popover.rs

1//! A floating panel of any content, anchored to a trigger.
2//!
3//! The app owns whether the popover is open: pass it to
4//! [`open`](Popover::open) and close it again when
5//! [`on_dismiss`](Popover::on_dismiss) arrives, after Escape or a click
6//! outside. The popover is not modal: the rest of the window stays usable
7//! while it is open, and the click that dismisses it still reaches what it
8//! landed on.
9//!
10//! Escape reaches the content first, so a menu, combobox or command list
11//! inside the popover closes before the popover does.
12
13use iced::advanced::layout::{self, Layout};
14use iced::advanced::widget::operation::Focusable;
15use iced::advanced::widget::{Operation, Tree, Widget};
16use iced::advanced::{Clipboard, Shell, overlay, renderer};
17use iced::keyboard::key::Named;
18use iced::widget::container;
19use iced::{
20    Element, Event, Length, Rectangle, Renderer, Size, Theme, Vector, keyboard, mouse, widget,
21};
22
23use crate::keys::{self, Chord, Keymap};
24use crate::overlay::anchored::{Placement, anchored, surface_style};
25use crate::theme::{Tokens, space};
26
27pub use crate::overlay::anchored::{Align, Side};
28
29/// Default panel width in logical pixels.
30pub const WIDTH: f32 = 288.0;
31
32/// A popover builder. Convert it into an [`Element`] to render.
33pub struct Popover<'a, Message> {
34    trigger: Element<'a, Message>,
35    content: Element<'a, Message>,
36    open: bool,
37    on_dismiss: Option<Message>,
38    placement: Placement,
39    width: Length,
40    padding: f32,
41    keymap: Keymap<Action>,
42}
43
44impl<Message> std::fmt::Debug for Popover<'_, Message> {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("Popover")
47            .field("open", &self.open)
48            .field("dismissable", &self.on_dismiss.is_some())
49            .field("placement", &self.placement)
50            .field("width", &self.width)
51            .field("padding", &self.padding)
52            .field("keymap", &self.keymap)
53            .finish_non_exhaustive()
54    }
55}
56
57/// Shows `content` in a panel next to `trigger` while
58/// [`open`](Popover::open) is true. The trigger opens it itself, usually a
59/// button whose message sets the app's flag.
60pub fn popover<'a, Message>(
61    trigger: impl Into<Element<'a, Message>>,
62    content: impl Into<Element<'a, Message>>,
63) -> Popover<'a, Message> {
64    Popover {
65        trigger: trigger.into(),
66        content: content.into(),
67        open: false,
68        on_dismiss: None,
69        placement: Placement::default(),
70        width: Length::Fixed(WIDTH),
71        padding: space::LG,
72        keymap: default_keymap(),
73    }
74}
75
76impl<'a, Message> Popover<'a, Message> {
77    pub fn open(mut self, open: bool) -> Self {
78        self.open = open;
79        self
80    }
81
82    /// Sent on Escape and on a press outside the panel and the trigger.
83    /// Without it the popover only closes when the app says so.
84    pub fn on_dismiss(mut self, message: Message) -> Self {
85        self.on_dismiss = Some(message);
86        self
87    }
88
89    /// Which side of the trigger the panel appears on. Defaults to the bottom.
90    pub fn side(mut self, side: Side) -> Self {
91        self.placement.side = side;
92        self
93    }
94
95    /// How the panel lines up with the trigger. Defaults to the centre.
96    pub fn align(mut self, align: Align) -> Self {
97        self.placement.align = align;
98        self
99    }
100
101    /// Space between the trigger and the panel.
102    pub fn gap(mut self, gap: f32) -> Self {
103        self.placement.gap = gap;
104        self
105    }
106
107    /// Defaults to [`WIDTH`]. `Length::Shrink` fits the content.
108    pub fn width(mut self, width: impl Into<Length>) -> Self {
109        self.width = width.into();
110        self
111    }
112
113    /// Space inside the panel's border. Defaults to `space::LG`.
114    pub fn padding(mut self, padding: f32) -> Self {
115        self.padding = padding;
116        self
117    }
118
119    /// Replaces the [`default_keymap`]. The open popover dismisses itself
120    /// on the chords bound to [`Action::Close`].
121    pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
122        self.keymap = keymap;
123        self
124    }
125}
126
127impl<'a, Message: Clone + 'a> From<Popover<'a, Message>> for Element<'a, Message> {
128    fn from(popover: Popover<'a, Message>) -> Self {
129        let Popover {
130            trigger,
131            content,
132            open,
133            on_dismiss,
134            placement,
135            width,
136            padding,
137            keymap,
138        } = popover;
139
140        let cycle = Cycle {
141            content,
142            keymap: keymap.clone(),
143        };
144        let panel = open.then(|| {
145            container(Element::new(cycle))
146                .width(width)
147                .padding(padding)
148                .style(|theme| style(&Tokens::of(theme)))
149                .into()
150        });
151        anchored(trigger)
152            .content(panel)
153            .placement(placement)
154            .pass_through(true)
155            .dismiss_keys(keymap.chords(&Action::Close).into_iter().cloned())
156            .on_dismiss_maybe(on_dismiss)
157            .into()
158    }
159}
160
161/// The panel style: the floating surface shared with menus.
162pub fn style(tokens: &Tokens) -> container::Style {
163    surface_style(tokens)
164}
165
166/// What a popover keyboard shortcut does.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
168pub enum Action {
169    /// Opens a closed popover and closes an open one. Not bound by default.
170    Toggle,
171    Close,
172    /// Moves focus from a field in the panel to the next one, wrapping.
173    FocusNext,
174    /// Moves focus from a field in the panel to the previous one, wrapping.
175    FocusPrevious,
176}
177
178impl Action {
179    /// Whether the popover should be open after this action, or `None`
180    /// when it stays as it is.
181    pub fn apply(self, open: bool) -> Option<bool> {
182        match self {
183            Action::Toggle => Some(!open),
184            Action::Close => open.then_some(false),
185            Action::FocusNext | Action::FocusPrevious => None,
186        }
187    }
188}
189
190impl keys::Action for Action {
191    const ALL: &'static [Self] = &[
192        Action::Toggle,
193        Action::Close,
194        Action::FocusNext,
195        Action::FocusPrevious,
196    ];
197
198    fn defaults() -> Keymap<Self> {
199        default_keymap()
200    }
201
202    fn name(self) -> &'static str {
203        match self {
204            Action::Toggle => "Toggle",
205            Action::Close => "Close",
206            Action::FocusNext => "FocusNext",
207            Action::FocusPrevious => "FocusPrevious",
208        }
209    }
210
211    fn description(self) -> &'static str {
212        match self {
213            Action::Toggle => "Opens the popover, or closes it when it is open.",
214            Action::Close => "Closes the popover.",
215            Action::FocusNext => {
216                "Moves from a field in the popover to its next field, wrapping at the end."
217            }
218            Action::FocusPrevious => {
219                "Moves from a field in the popover to its previous field, wrapping at the start."
220            }
221        }
222    }
223}
224
225/// The default popover shortcuts:
226///
227/// | Keys | Action |
228/// | --- | --- |
229/// | `Escape` | [`Action::Close`] |
230/// | `Tab` | [`Action::FocusNext`] |
231/// | `Shift+Tab` | [`Action::FocusPrevious`] |
232///
233/// The focus actions only apply while a field inside the panel has focus,
234/// so Tab elsewhere moves on as usual.
235///
236/// An open popover with [`on_dismiss`](Popover::on_dismiss) resolves
237/// [`Action::Close`] itself, after its content has had the key, so pass a
238/// changed keymap with [`Popover::keymap`]. [`Action::Toggle`] has no
239/// default chord because a popover usually belongs to one button; bind one
240/// when a popover deserves an app-wide shortcut, and route it from
241/// [`keys::subscription`] with [`Action::apply`].
242pub fn default_keymap() -> Keymap<Action> {
243    Keymap::new()
244        .bind(Chord::named(Named::Escape), Action::Close)
245        .bind(Chord::named(Named::Tab), Action::FocusNext)
246        .bind(Chord::named(Named::Tab).shift(), Action::FocusPrevious)
247}
248
249/// The field to focus after `forward` or backward movement from the
250/// focused one, wrapping at either end, given whether each field in the
251/// panel has focus. `None` when no field in the panel has focus.
252fn cycle_target(focused: &[bool], forward: bool) -> Option<usize> {
253    let current = focused.iter().position(|focused| *focused)?;
254    let count = focused.len();
255    Some(if forward {
256        (current + 1) % count
257    } else {
258        (current + count - 1) % count
259    })
260}
261
262/// The panel's content, with Tab and Shift+Tab moving between its fields
263/// while one of them has focus.
264struct Cycle<'a, Message> {
265    content: Element<'a, Message>,
266    keymap: Keymap<Action>,
267}
268
269/// Whether each focusable widget has focus, in tree order.
270#[derive(Default)]
271struct Survey {
272    focused: Vec<bool>,
273}
274
275impl Operation for Survey {
276    fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) {
277        operate(self);
278    }
279
280    fn focusable(
281        &mut self,
282        _id: Option<&widget::Id>,
283        _bounds: Rectangle,
284        state: &mut dyn Focusable,
285    ) {
286        self.focused.push(state.is_focused());
287    }
288}
289
290/// Focuses the focusable widget at `target` and unfocuses the others.
291struct Apply {
292    target: usize,
293    index: usize,
294}
295
296impl Operation for Apply {
297    fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) {
298        operate(self);
299    }
300
301    fn focusable(
302        &mut self,
303        _id: Option<&widget::Id>,
304        _bounds: Rectangle,
305        state: &mut dyn Focusable,
306    ) {
307        if self.index == self.target {
308            state.focus();
309        } else {
310            state.unfocus();
311        }
312        self.index += 1;
313    }
314}
315
316impl<Message> Cycle<'_, Message> {
317    /// The movement a key press asks for, if it is a focus chord.
318    fn movement(&self, event: &Event) -> Option<bool> {
319        let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event else {
320            return None;
321        };
322        match self.keymap.resolve(key, *modifiers)? {
323            Action::FocusNext => Some(true),
324            Action::FocusPrevious => Some(false),
325            Action::Toggle | Action::Close => None,
326        }
327    }
328}
329
330impl<Message> Widget<Message, Theme, Renderer> for Cycle<'_, Message> {
331    fn size(&self) -> Size<Length> {
332        self.content.as_widget().size()
333    }
334
335    fn size_hint(&self) -> Size<Length> {
336        self.content.as_widget().size_hint()
337    }
338
339    fn children(&self) -> Vec<Tree> {
340        vec![Tree::new(&self.content)]
341    }
342
343    fn diff(&self, tree: &mut Tree) {
344        tree.diff_children(std::slice::from_ref(&self.content));
345    }
346
347    fn layout(
348        &mut self,
349        tree: &mut Tree,
350        renderer: &Renderer,
351        limits: &layout::Limits,
352    ) -> layout::Node {
353        self.content
354            .as_widget_mut()
355            .layout(&mut tree.children[0], renderer, limits)
356    }
357
358    fn draw(
359        &self,
360        tree: &Tree,
361        renderer: &mut Renderer,
362        theme: &Theme,
363        style: &renderer::Style,
364        layout: Layout<'_>,
365        cursor: mouse::Cursor,
366        viewport: &Rectangle,
367    ) {
368        self.content.as_widget().draw(
369            &tree.children[0],
370            renderer,
371            theme,
372            style,
373            layout,
374            cursor,
375            viewport,
376        );
377    }
378
379    fn operate(
380        &mut self,
381        tree: &mut Tree,
382        layout: Layout<'_>,
383        renderer: &Renderer,
384        operation: &mut dyn Operation,
385    ) {
386        self.content
387            .as_widget_mut()
388            .operate(&mut tree.children[0], layout, renderer, operation);
389    }
390
391    fn update(
392        &mut self,
393        tree: &mut Tree,
394        event: &Event,
395        layout: Layout<'_>,
396        cursor: mouse::Cursor,
397        renderer: &Renderer,
398        clipboard: &mut dyn Clipboard,
399        shell: &mut Shell<'_, Message>,
400        viewport: &Rectangle,
401    ) {
402        let content = &mut tree.children[0];
403        self.content.as_widget_mut().update(
404            content, event, layout, cursor, renderer, clipboard, shell, viewport,
405        );
406        if shell.is_event_captured() {
407            return;
408        }
409        let Some(forward) = self.movement(event) else {
410            return;
411        };
412        let mut survey = Survey::default();
413        self.content
414            .as_widget_mut()
415            .operate(content, layout, renderer, &mut survey);
416        let Some(target) = cycle_target(&survey.focused, forward) else {
417            return;
418        };
419        self.content.as_widget_mut().operate(
420            content,
421            layout,
422            renderer,
423            &mut Apply { target, index: 0 },
424        );
425        shell.capture_event();
426        shell.request_redraw();
427    }
428
429    fn mouse_interaction(
430        &self,
431        tree: &Tree,
432        layout: Layout<'_>,
433        cursor: mouse::Cursor,
434        viewport: &Rectangle,
435        renderer: &Renderer,
436    ) -> mouse::Interaction {
437        self.content.as_widget().mouse_interaction(
438            &tree.children[0],
439            layout,
440            cursor,
441            viewport,
442            renderer,
443        )
444    }
445
446    fn overlay<'b>(
447        &'b mut self,
448        tree: &'b mut Tree,
449        layout: Layout<'b>,
450        renderer: &Renderer,
451        viewport: &Rectangle,
452        translation: Vector,
453    ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
454        self.content.as_widget_mut().overlay(
455            &mut tree.children[0],
456            layout,
457            renderer,
458            viewport,
459            translation,
460        )
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::theme::{dark, light};
468    use iced::widget::text;
469
470    #[test]
471    fn builder_defaults_to_closed_below_and_centred() {
472        let popover: Popover<'_, ()> = popover(text("Open"), text("Content"));
473        assert!(!popover.open);
474        assert!(popover.on_dismiss.is_none());
475        assert_eq!(popover.placement, Placement::default());
476        assert_eq!(popover.placement.side, Side::Bottom);
477        assert_eq!(popover.placement.align, Align::Center);
478        assert_eq!(popover.width, Length::Fixed(WIDTH));
479        assert_eq!(popover.padding, space::LG);
480        assert_eq!(popover.keymap, default_keymap());
481    }
482
483    #[test]
484    fn builder_sets_every_option() {
485        let popover: Popover<'_, u8> = popover(text("Open"), text("Content"))
486            .open(true)
487            .on_dismiss(1)
488            .side(Side::Right)
489            .align(Align::End)
490            .gap(10.0)
491            .width(Length::Shrink)
492            .padding(4.0)
493            .keymap(Keymap::new());
494        assert!(popover.keymap.is_empty());
495        assert!(popover.open);
496        assert_eq!(popover.on_dismiss, Some(1));
497        assert_eq!(popover.placement.side, Side::Right);
498        assert_eq!(popover.placement.align, Align::End);
499        assert_eq!(popover.placement.gap, 10.0);
500        assert_eq!(popover.width, Length::Shrink);
501        assert_eq!(popover.padding, 4.0);
502    }
503
504    #[test]
505    fn panel_uses_the_shared_surface_in_both_themes() {
506        for theme in [light(), dark()] {
507            let tokens = Tokens::of(&theme);
508            assert_eq!(style(&tokens), surface_style(&tokens));
509        }
510    }
511
512    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
513        let chord: Chord = chord.parse().unwrap();
514        keymap.resolve(chord.key(), chord.modifiers())
515    }
516
517    #[test]
518    fn default_keymap_closes_on_escape_and_moves_focus_on_tab() {
519        let keymap = default_keymap();
520        assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
521        assert_eq!(press(&keymap, "Tab"), Some(Action::FocusNext));
522        assert_eq!(press(&keymap, "Shift+Tab"), Some(Action::FocusPrevious));
523        assert!(keymap.chords(&Action::Toggle).is_empty());
524        assert_eq!(keymap.len(), 3);
525        assert_eq!(Keymap::<Action>::defaults(), keymap);
526    }
527
528    #[test]
529    fn focus_moves_between_fields_and_wraps() {
530        assert_eq!(cycle_target(&[true, false, false], true), Some(1));
531        assert_eq!(cycle_target(&[false, false, true], true), Some(0));
532        assert_eq!(cycle_target(&[true, false, false], false), Some(2));
533        assert_eq!(cycle_target(&[false, true], false), Some(0));
534        assert_eq!(
535            cycle_target(&[true], true),
536            Some(0),
537            "a lone field keeps focus"
538        );
539        assert_eq!(
540            cycle_target(&[false, false], true),
541            None,
542            "no field in the panel has focus"
543        );
544        assert_eq!(cycle_target(&[], true), None);
545    }
546
547    #[test]
548    fn focus_actions_leave_the_open_state_alone() {
549        assert_eq!(Action::FocusNext.apply(true), None);
550        assert_eq!(Action::FocusPrevious.apply(false), None);
551    }
552
553    #[test]
554    fn keymap_overrides_and_unbinding() {
555        let keymap = default_keymap()
556            .bind("I".parse().unwrap(), Action::Toggle)
557            .unbind(&"Escape".parse().unwrap());
558        assert_eq!(press(&keymap, "i"), Some(Action::Toggle));
559        assert_eq!(press(&keymap, "Escape"), None);
560    }
561
562    #[test]
563    fn actions_map_to_the_next_open_state() {
564        assert_eq!(Action::Toggle.apply(false), Some(true));
565        assert_eq!(Action::Toggle.apply(true), Some(false));
566        assert_eq!(Action::Close.apply(true), Some(false));
567        assert_eq!(Action::Close.apply(false), None);
568    }
569}