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::keyboard::key::Named;
14use iced::widget::container;
15use iced::{Element, Length};
16
17use crate::keys::{self, Chord, Keymap};
18use crate::overlay::anchored::{Placement, anchored, surface_style};
19use crate::theme::{Tokens, space};
20
21pub use crate::overlay::anchored::{Align, Side};
22
23/// Default panel width in logical pixels.
24pub const WIDTH: f32 = 288.0;
25
26/// A popover builder. Convert it into an [`Element`] to render.
27pub struct Popover<'a, Message> {
28    trigger: Element<'a, Message>,
29    content: Element<'a, Message>,
30    open: bool,
31    on_dismiss: Option<Message>,
32    placement: Placement,
33    width: Length,
34    padding: f32,
35    keymap: Keymap<Action>,
36}
37
38impl<Message> std::fmt::Debug for Popover<'_, Message> {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct("Popover")
41            .field("open", &self.open)
42            .field("dismissable", &self.on_dismiss.is_some())
43            .field("placement", &self.placement)
44            .field("width", &self.width)
45            .field("padding", &self.padding)
46            .field("keymap", &self.keymap)
47            .finish_non_exhaustive()
48    }
49}
50
51/// Shows `content` in a panel next to `trigger` while
52/// [`open`](Popover::open) is true. The trigger opens it itself, usually a
53/// button whose message sets the app's flag.
54pub fn popover<'a, Message>(
55    trigger: impl Into<Element<'a, Message>>,
56    content: impl Into<Element<'a, Message>>,
57) -> Popover<'a, Message> {
58    Popover {
59        trigger: trigger.into(),
60        content: content.into(),
61        open: false,
62        on_dismiss: None,
63        placement: Placement::default(),
64        width: Length::Fixed(WIDTH),
65        padding: space::LG,
66        keymap: default_keymap(),
67    }
68}
69
70impl<'a, Message> Popover<'a, Message> {
71    pub fn open(mut self, open: bool) -> Self {
72        self.open = open;
73        self
74    }
75
76    /// Sent on Escape and on a press outside the panel and the trigger.
77    /// Without it the popover only closes when the app says so.
78    pub fn on_dismiss(mut self, message: Message) -> Self {
79        self.on_dismiss = Some(message);
80        self
81    }
82
83    /// Which side of the trigger the panel appears on. Defaults to the bottom.
84    pub fn side(mut self, side: Side) -> Self {
85        self.placement.side = side;
86        self
87    }
88
89    /// How the panel lines up with the trigger. Defaults to the centre.
90    pub fn align(mut self, align: Align) -> Self {
91        self.placement.align = align;
92        self
93    }
94
95    /// Space between the trigger and the panel.
96    pub fn gap(mut self, gap: f32) -> Self {
97        self.placement.gap = gap;
98        self
99    }
100
101    /// Defaults to [`WIDTH`]. `Length::Shrink` fits the content.
102    pub fn width(mut self, width: impl Into<Length>) -> Self {
103        self.width = width.into();
104        self
105    }
106
107    /// Space inside the panel's border. Defaults to `space::LG`.
108    pub fn padding(mut self, padding: f32) -> Self {
109        self.padding = padding;
110        self
111    }
112
113    /// Replaces the [`default_keymap`]. The open popover dismisses itself
114    /// on the chords bound to [`Action::Close`].
115    pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
116        self.keymap = keymap;
117        self
118    }
119}
120
121impl<'a, Message: Clone + 'a> From<Popover<'a, Message>> for Element<'a, Message> {
122    fn from(popover: Popover<'a, Message>) -> Self {
123        let Popover {
124            trigger,
125            content,
126            open,
127            on_dismiss,
128            placement,
129            width,
130            padding,
131            keymap,
132        } = popover;
133
134        let panel = open.then(|| {
135            container(content)
136                .width(width)
137                .padding(padding)
138                .style(|theme| style(&Tokens::of(theme)))
139                .into()
140        });
141        anchored(trigger)
142            .content(panel)
143            .placement(placement)
144            .pass_through(true)
145            .dismiss_keys(keymap.chords(&Action::Close).into_iter().cloned())
146            .on_dismiss_maybe(on_dismiss)
147            .into()
148    }
149}
150
151/// The panel style: the floating surface shared with menus.
152pub fn style(tokens: &Tokens) -> container::Style {
153    surface_style(tokens)
154}
155
156/// What a popover keyboard shortcut does.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
158pub enum Action {
159    /// Opens a closed popover and closes an open one. Not bound by default.
160    Toggle,
161    Close,
162}
163
164impl Action {
165    /// Whether the popover should be open after this action, or `None`
166    /// when it stays as it is.
167    pub fn apply(self, open: bool) -> Option<bool> {
168        match self {
169            Action::Toggle => Some(!open),
170            Action::Close => open.then_some(false),
171        }
172    }
173}
174
175impl keys::Action for Action {
176    const ALL: &'static [Self] = &[Action::Toggle, Action::Close];
177
178    fn defaults() -> Keymap<Self> {
179        default_keymap()
180    }
181
182    fn name(self) -> &'static str {
183        match self {
184            Action::Toggle => "Toggle",
185            Action::Close => "Close",
186        }
187    }
188
189    fn description(self) -> &'static str {
190        match self {
191            Action::Toggle => "Opens the popover, or closes it when it is open.",
192            Action::Close => "Closes the popover.",
193        }
194    }
195}
196
197/// The default popover shortcuts:
198///
199/// | Keys | Action |
200/// | --- | --- |
201/// | `Escape` | [`Action::Close`] |
202///
203/// An open popover with [`on_dismiss`](Popover::on_dismiss) resolves
204/// [`Action::Close`] itself, after its content has had the key, so pass a
205/// changed keymap with [`Popover::keymap`]. [`Action::Toggle`] has no
206/// default chord because a popover usually belongs to one button; bind one
207/// when a popover deserves an app-wide shortcut, and route it from
208/// [`keys::subscription`] with [`Action::apply`].
209pub fn default_keymap() -> Keymap<Action> {
210    Keymap::new().bind(Chord::named(Named::Escape), Action::Close)
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::theme::{dark, light};
217    use iced::widget::text;
218
219    #[test]
220    fn builder_defaults_to_closed_below_and_centred() {
221        let popover: Popover<'_, ()> = popover(text("Open"), text("Content"));
222        assert!(!popover.open);
223        assert!(popover.on_dismiss.is_none());
224        assert_eq!(popover.placement, Placement::default());
225        assert_eq!(popover.placement.side, Side::Bottom);
226        assert_eq!(popover.placement.align, Align::Center);
227        assert_eq!(popover.width, Length::Fixed(WIDTH));
228        assert_eq!(popover.padding, space::LG);
229        assert_eq!(popover.keymap, default_keymap());
230    }
231
232    #[test]
233    fn builder_sets_every_option() {
234        let popover: Popover<'_, u8> = popover(text("Open"), text("Content"))
235            .open(true)
236            .on_dismiss(1)
237            .side(Side::Right)
238            .align(Align::End)
239            .gap(10.0)
240            .width(Length::Shrink)
241            .padding(4.0)
242            .keymap(Keymap::new());
243        assert!(popover.keymap.is_empty());
244        assert!(popover.open);
245        assert_eq!(popover.on_dismiss, Some(1));
246        assert_eq!(popover.placement.side, Side::Right);
247        assert_eq!(popover.placement.align, Align::End);
248        assert_eq!(popover.placement.gap, 10.0);
249        assert_eq!(popover.width, Length::Shrink);
250        assert_eq!(popover.padding, 4.0);
251    }
252
253    #[test]
254    fn panel_uses_the_shared_surface_in_both_themes() {
255        for theme in [light(), dark()] {
256            let tokens = Tokens::of(&theme);
257            assert_eq!(style(&tokens), surface_style(&tokens));
258        }
259    }
260
261    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
262        let chord: Chord = chord.parse().unwrap();
263        keymap.resolve(chord.key(), chord.modifiers())
264    }
265
266    #[test]
267    fn default_keymap_closes_on_escape_only() {
268        let keymap = default_keymap();
269        assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
270        assert!(keymap.chords(&Action::Toggle).is_empty());
271        assert_eq!(keymap.len(), 1);
272        assert_eq!(Keymap::<Action>::defaults(), keymap);
273    }
274
275    #[test]
276    fn keymap_overrides_and_unbinding() {
277        let keymap = default_keymap()
278            .bind("I".parse().unwrap(), Action::Toggle)
279            .unbind(&"Escape".parse().unwrap());
280        assert_eq!(press(&keymap, "i"), Some(Action::Toggle));
281        assert_eq!(press(&keymap, "Escape"), None);
282    }
283
284    #[test]
285    fn actions_map_to_the_next_open_state() {
286        assert_eq!(Action::Toggle.apply(false), Some(true));
287        assert_eq!(Action::Toggle.apply(true), Some(false));
288        assert_eq!(Action::Close.apply(true), Some(false));
289        assert_eq!(Action::Close.apply(false), None);
290    }
291}