Skip to main content

iced_m3/widget/
drop_down_menu.rs

1use std::{cell::Cell, rc::Rc};
2
3use iced::advanced::Clipboard;
4use iced_widget::core::{
5    Element, Event, Layout, Length, Point, Rectangle, Shell, Size, Vector, Widget, keyboard,
6    layout::{Limits, Node},
7    mouse::{self, Cursor, Interaction},
8    overlay,
9    renderer::Style,
10    widget::{Operation, Tree, tree},
11};
12
13#[derive(Debug)]
14struct State {
15    position: Option<Point>,
16}
17
18/// Describes which edge of `trigger` the menu is anchored to, and where along that edge it is placed.
19///
20/// Variant names are `EdgePosition`:
21/// - `Edge` is the side of `trigger` the menu attaches to
22/// - `Position` is the menu's alignment along that edge
23///
24/// For example, [`TopLeft`](Placement::TopLeft) means the menu is attached to the top edge of
25/// `trigger` and aligned to the left. [`LeftTop`](Placement::LeftTop) means it is attached to the
26/// left edge and aligned to the top.
27#[derive(Debug, Default, Clone, Copy)]
28pub enum Placement {
29    TopLeft,
30    TopCenter,
31    TopRight,
32    RightTop,
33    RightCenter,
34    RightBottom,
35    BottomRight,
36    BottomCenter,
37    #[default]
38    BottomLeft,
39    LeftBottom,
40    LeftCenter,
41    LeftTop,
42}
43
44impl Placement {
45    #[must_use]
46    fn flip_x(self) -> Self {
47        match self {
48            Self::TopLeft => Self::TopRight,
49            Self::TopRight => Self::TopLeft,
50            Self::RightTop => Self::LeftTop,
51            Self::RightCenter => Self::LeftCenter,
52            Self::RightBottom => Self::LeftBottom,
53            Self::BottomRight => Self::BottomLeft,
54            Self::BottomLeft => Self::BottomRight,
55            Self::LeftBottom => Self::RightBottom,
56            Self::LeftCenter => Self::RightCenter,
57            Self::LeftTop => Self::RightTop,
58            Self::TopCenter | Self::BottomCenter => self,
59        }
60    }
61
62    #[must_use]
63    fn flip_y(self) -> Self {
64        match self {
65            Self::TopLeft => Self::BottomLeft,
66            Self::TopCenter => Self::BottomCenter,
67            Self::TopRight => Self::BottomRight,
68            Self::RightTop => Self::RightBottom,
69            Self::RightBottom => Self::RightTop,
70            Self::BottomRight => Self::TopRight,
71            Self::BottomCenter => Self::TopCenter,
72            Self::BottomLeft => Self::TopLeft,
73            Self::LeftBottom => Self::LeftTop,
74            Self::LeftTop => Self::LeftBottom,
75            Self::LeftCenter | Self::RightCenter => self,
76        }
77    }
78
79    // TODO: Make sure the menu stays in bounds
80    fn offset(&self, bounds: &Rectangle, overlay_bounds: &Rectangle) -> Vector {
81        match self {
82            Placement::TopLeft => {
83                Vector::new(-overlay_bounds.width + bounds.width, -overlay_bounds.height)
84            }
85            Placement::TopCenter => Vector::new(
86                (-overlay_bounds.width + bounds.width) / 2.0,
87                -overlay_bounds.height,
88            ),
89            Placement::TopRight => Vector::new(0.0, -overlay_bounds.height),
90            Placement::RightCenter => {
91                Vector::new(bounds.width, (-overlay_bounds.height + bounds.height) / 2.0)
92            }
93            Placement::RightTop => {
94                Vector::new(bounds.width, -overlay_bounds.height + bounds.height)
95            }
96            Placement::RightBottom => Vector::new(bounds.width, 0.0),
97            Placement::BottomRight => Vector::new(0.0, bounds.height),
98            Placement::BottomCenter => {
99                Vector::new((-overlay_bounds.width + bounds.width) / 2.0, bounds.height)
100            }
101            Placement::BottomLeft => {
102                Vector::new(-overlay_bounds.width + bounds.width, bounds.height)
103            }
104            Placement::LeftBottom => Vector::new(-overlay_bounds.width, 0.0),
105            Placement::LeftCenter => Vector::new(
106                -overlay_bounds.width,
107                (-overlay_bounds.height + bounds.height) / 2.0,
108            ),
109            Placement::LeftTop => Vector::new(
110                -overlay_bounds.width,
111                -overlay_bounds.height + bounds.height,
112            ),
113        }
114    }
115}
116
117pub struct DropDownMenu<'a, Message, Theme, Renderer> {
118    // TODO: Also expose whether the trigger is hovered
119    trigger: Box<dyn Fn(bool) -> Element<'a, Message, Theme, Renderer> + 'a>,
120    menu: Option<Element<'a, Message, Theme, Renderer>>,
121    overlay_bounds: Option<Rectangle>,
122    trigger_cached: Option<Element<'a, Message, Theme, Renderer>>,
123    placement: Placement,
124    open_cached: Rc<Cell<bool>>,
125    just_closed: Rc<Cell<bool>>,
126    menu_transparent: bool,
127    trigger_transparent: bool,
128    trigger_bounds: Option<Rectangle>,
129}
130
131impl<'a, Message, Theme, Renderer> DropDownMenu<'a, Message, Theme, Renderer> {
132    pub fn new(
133        trigger: impl Fn(bool) -> Element<'a, Message, Theme, Renderer> + 'a,
134        menu: Option<impl Into<Element<'a, Message, Theme, Renderer>>>,
135        placement: Placement,
136    ) -> Self {
137        Self {
138            trigger: Box::new(trigger),
139            menu: menu.map(|e| e.into()),
140            overlay_bounds: None,
141            trigger_cached: None,
142            placement,
143            open_cached: Rc::new(Cell::new(false)),
144            just_closed: Rc::new(Cell::new(false)),
145            menu_transparent: false,
146            trigger_transparent: false,
147            trigger_bounds: None,
148        }
149    }
150
151    pub fn menu_transparent(mut self, transparent: bool) -> Self {
152        self.menu_transparent = transparent;
153        self
154    }
155    pub fn trigger_transparent(mut self, transparent: bool) -> Self {
156        self.trigger_transparent = transparent;
157        self
158    }
159}
160
161impl<Message, Theme, Renderer: iced::advanced::Renderer> Widget<Message, Theme, Renderer>
162    for DropDownMenu<'_, Message, Theme, Renderer>
163{
164    fn size(&self) -> Size<Length> {
165        (self.trigger)(self.open_cached.get()).as_widget().size()
166    }
167
168    fn layout(&mut self, tree: &mut Tree, renderer: &Renderer, limits: &Limits) -> Node {
169        if let Some(menu) = &mut self.menu {
170            let overlay_bounds = menu
171                .as_widget_mut()
172                .layout(&mut tree.children[1], renderer, &Limits::NONE)
173                .bounds();
174            self.overlay_bounds = Some(overlay_bounds);
175        }
176        self.trigger_cached = Some((self.trigger)(
177            tree.state.downcast_ref::<State>().position.is_some(),
178        ));
179        let trigger = self
180            .trigger_cached
181            .as_mut()
182            .unwrap()
183            .as_widget_mut()
184            .layout(&mut tree.children[0], renderer, limits);
185        self.trigger_bounds = Some(trigger.bounds());
186        trigger
187    }
188
189    fn draw(
190        &self,
191        tree: &Tree,
192        renderer: &mut Renderer,
193        theme: &Theme,
194        style: &Style,
195        layout: Layout<'_>,
196        cursor: Cursor,
197        viewport: &Rectangle,
198    ) {
199        (self.trigger)(tree.state.downcast_ref::<State>().position.is_some())
200            .as_widget()
201            .draw(
202                &tree.children[0],
203                renderer,
204                theme,
205                style,
206                layout,
207                cursor,
208                viewport,
209            );
210    }
211
212    fn tag(&self) -> tree::Tag {
213        tree::Tag::of::<State>()
214    }
215
216    fn state(&self) -> tree::State {
217        tree::State::new(State { position: None })
218    }
219
220    fn children(&self) -> Vec<Tree> {
221        if let Some(menu) = &self.menu {
222            vec![
223                Tree::new((self.trigger)(self.open_cached.get())),
224                Tree::new(menu),
225            ]
226        } else {
227            vec![Tree::new((self.trigger)(self.open_cached.get()))]
228        }
229    }
230
231    fn diff(&self, tree: &mut Tree) {
232        if let Some(menu) = &self.menu {
233            tree.diff_children(&[
234                &(self.trigger)(tree.state.downcast_ref::<State>().position.is_some()),
235                menu,
236            ]);
237        } else {
238            tree.diff_children(&[&(self.trigger)(
239                tree.state.downcast_ref::<State>().position.is_some(),
240            )]);
241        }
242    }
243
244    fn operate(
245        &mut self,
246        tree: &mut Tree,
247        layout: Layout<'_>,
248        renderer: &Renderer,
249        operation: &mut dyn Operation,
250    ) {
251        (self.trigger)(tree.state.downcast_ref::<State>().position.is_some())
252            .as_widget_mut()
253            .operate(&mut tree.children[0], layout, renderer, operation);
254    }
255
256    fn update(
257        &mut self,
258        tree: &mut Tree,
259        event: &Event,
260        layout: Layout<'_>,
261        cursor: Cursor,
262        renderer: &Renderer,
263        clipboard: &mut dyn Clipboard,
264        shell: &mut Shell<'_, Message>,
265        viewport: &Rectangle,
266    ) {
267        (self.trigger)(tree.state.downcast_ref::<State>().position.is_some())
268            .as_widget_mut()
269            .update(
270                &mut tree.children[0],
271                event,
272                layout,
273                cursor,
274                renderer,
275                clipboard,
276                shell,
277                viewport,
278            );
279
280        if self.menu.is_none() {
281            return;
282        }
283
284        if shell.is_event_captured() {
285            self.just_closed.set(false);
286            return;
287        }
288
289        let state = tree.state.downcast_mut::<State>();
290        if let Some(pos) = cursor.position()
291            && layout.bounds().contains(pos)
292        {
293            if !self.trigger_transparent
294                && let Event::Mouse(_) = event
295            {
296                shell.capture_event();
297            }
298
299            if let Event::Mouse(mouse::Event::ButtonPressed(..)) = event {
300                if self.just_closed.get() {
301                    state.position = None;
302                    self.open_cached.set(false);
303                } else {
304                    state.position = Some(layout.bounds().position());
305                    self.open_cached.set(true);
306                    shell.invalidate_widgets();
307                }
308                shell.request_redraw();
309                self.just_closed.set(false);
310                shell.capture_event();
311            }
312        }
313
314        if state.position.is_some() {
315            state.position = Some(layout.bounds().position());
316        }
317    }
318
319    fn mouse_interaction(
320        &self,
321        tree: &Tree,
322        layout: Layout<'_>,
323        cursor: Cursor,
324        viewport: &Rectangle,
325        renderer: &Renderer,
326    ) -> Interaction {
327        let interaction = (self.trigger)(self.open_cached.get())
328            .as_widget()
329            .mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer);
330
331        if interaction == Interaction::None
332            && cursor.is_over(layout.bounds())
333            && self.menu.is_some()
334        {
335            Interaction::Pointer
336        } else {
337            interaction
338        }
339    }
340
341    fn overlay<'a>(
342        &'a mut self,
343        tree: &'a mut Tree,
344        layout: Layout<'a>,
345        renderer: &Renderer,
346        viewport: &Rectangle,
347        translation: Vector,
348    ) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
349        let state = tree.state.downcast_mut::<State>();
350
351        let children = if let Some(menu) = &mut self.menu {
352            let [first, second] = &mut *tree.children else {
353                unreachable!();
354            };
355            [
356                // NOTE: I think this might cause issues if `trigger_cached` is not assigned, it won't
357                // display its overlay??
358                self.trigger_cached.as_mut().and_then(|trigger_cached| {
359                    trigger_cached.as_widget_mut().overlay(
360                        first,
361                        layout,
362                        renderer,
363                        viewport,
364                        translation,
365                    )
366                }),
367                state.position.map(|position| {
368                    overlay::Element::new(Box::new(Overlay {
369                        menu,
370                        tree: second,
371                        state,
372                        position: position + translation,
373                        open_cached: self.open_cached.clone(),
374                        just_closed: self.just_closed.clone(),
375                        transparent: &self.menu_transparent,
376                        placement: self.placement,
377                        trigger_bounds: self.trigger_bounds.unwrap(),
378                    }))
379                }),
380            ]
381            .into_iter()
382            .flatten()
383            .collect::<Vec<_>>()
384        } else {
385            vec![]
386        };
387
388        (!children.is_empty()).then(|| overlay::Group::with_children(children).overlay())
389    }
390}
391
392impl<'a, Message: 'a, Theme: 'a, Renderer: iced::advanced::Renderer + 'a>
393    From<DropDownMenu<'a, Message, Theme, Renderer>> for Element<'a, Message, Theme, Renderer>
394{
395    fn from(value: DropDownMenu<'a, Message, Theme, Renderer>) -> Self {
396        Self::new(value)
397    }
398}
399
400struct Overlay<'a, 'b, Message, Theme, Renderer> {
401    menu: &'b mut Element<'a, Message, Theme, Renderer>,
402    tree: &'b mut Tree,
403    state: &'b mut State,
404    position: Point,
405    placement: Placement,
406    open_cached: Rc<Cell<bool>>,
407    just_closed: Rc<Cell<bool>>,
408    transparent: &'b bool,
409    trigger_bounds: Rectangle,
410}
411
412// FIX: Parent overlay acting all weird while a child `DropDownMenu` overlay is opened:
413// 1. The parent overlay passes down mouse hover events when it's pressed
414// 2. The cursor hovered over the parent overlay has an icon that reflects the widget below the
415//   overlay, not the overlay itself (eg. a button in the overlay is hovered but the mouse pointer
416//   uses the default icon because there's no interactive widget below the overlay)
417impl<Message, Theme, Renderer: iced::advanced::Renderer> overlay::Overlay<Message, Theme, Renderer>
418    for Overlay<'_, '_, Message, Theme, Renderer>
419{
420    fn layout(&mut self, renderer: &Renderer, bounds: Size) -> Node {
421        let layout = self
422            .menu
423            .as_widget_mut()
424            .layout(self.tree, renderer, &Limits::new(Size::ZERO, bounds))
425            .move_to(self.position);
426
427        let overlay_bounds = layout.bounds();
428        let offset = self.placement.offset(&self.trigger_bounds, &overlay_bounds);
429
430        let over_right = overlay_bounds.width + overlay_bounds.x + offset.x > bounds.width;
431        let over_left = overlay_bounds.x + offset.x < 0.0;
432
433        let placement_flipped = self.placement.flip_x();
434        let offset_flipped = placement_flipped.offset(&self.trigger_bounds, &overlay_bounds);
435        let flipped_fits_right =
436            overlay_bounds.width + overlay_bounds.x + offset_flipped.x < bounds.width;
437        let flipped_fits_left = overlay_bounds.x + offset_flipped.x > 0.0;
438
439        if (over_right && flipped_fits_left) || (over_left && flipped_fits_right) {
440            self.placement = placement_flipped;
441        }
442
443        let over_top = overlay_bounds.y + offset.y < 0.0;
444        let over_bottom = overlay_bounds.height + overlay_bounds.y + offset.y > bounds.height;
445
446        let placement_flipped = self.placement.flip_y();
447        let offset_flipped = placement_flipped.offset(&self.trigger_bounds, &overlay_bounds);
448        let flipped_fits_top = overlay_bounds.y + offset_flipped.y > 0.0;
449        let flipped_fits_bottom =
450            overlay_bounds.height + overlay_bounds.y + offset_flipped.y < bounds.height;
451
452        if (over_top && flipped_fits_bottom) || (over_bottom && flipped_fits_top) {
453            self.placement = placement_flipped;
454        }
455
456        self.menu
457            .as_widget_mut()
458            .layout(self.tree, renderer, &Limits::new(Size::ZERO, bounds))
459            .move_to(self.position + self.placement.offset(&self.trigger_bounds, &overlay_bounds))
460    }
461
462    fn draw(
463        &self,
464        renderer: &mut Renderer,
465        theme: &Theme,
466        style: &Style,
467        layout: Layout<'_>,
468        cursor: Cursor,
469    ) {
470        renderer.with_layer(Rectangle::INFINITE, |renderer| {
471            self.menu.as_widget().draw(
472                self.tree,
473                renderer,
474                theme,
475                style,
476                layout,
477                cursor,
478                &layout.bounds(),
479            );
480        });
481    }
482
483    fn operate(&mut self, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation) {
484        self.menu
485            .as_widget_mut()
486            .operate(self.tree, layout, renderer, operation);
487    }
488
489    fn update(
490        &mut self,
491        event: &Event,
492        layout: Layout<'_>,
493        cursor: Cursor,
494        renderer: &Renderer,
495        clipboard: &mut dyn Clipboard,
496        shell: &mut Shell<'_, Message>,
497    ) {
498        let was_event_captured = shell.is_event_captured();
499
500        self.menu.as_widget_mut().update(
501            self.tree,
502            event,
503            layout,
504            cursor,
505            renderer,
506            clipboard,
507            shell,
508            &layout.bounds(),
509        );
510
511        if was_event_captured {
512            return;
513        }
514
515        match event {
516            Event::Mouse(mouse::Event::ButtonPressed { .. }) => {
517                if !cursor.is_over(layout.bounds()) {
518                    self.state.position = None;
519                    self.just_closed.set(true);
520                    self.open_cached.set(false);
521                    shell.invalidate_widgets();
522                    shell.request_redraw();
523                }
524            }
525            Event::Mouse(mouse::Event::ButtonReleased { .. })
526                if shell.is_event_captured() && cursor.is_over(layout.bounds()) =>
527            {
528                self.state.position = None;
529                self.just_closed.set(true);
530                self.open_cached.set(false);
531                shell.invalidate_widgets();
532                shell.request_redraw();
533            }
534            Event::Keyboard(keyboard::Event::KeyPressed {
535                key: keyboard::Key::Named(keyboard::key::Named::Escape),
536                ..
537            }) => {
538                self.state.position = None;
539                self.just_closed.set(true);
540                self.open_cached.set(false);
541                shell.invalidate_widgets();
542                shell.request_redraw();
543            }
544            _ => {}
545        }
546
547        if let Event::Mouse(_) = event
548            && !*self.transparent
549            && cursor.is_over(layout.bounds())
550        {
551            shell.capture_event();
552        }
553    }
554
555    fn mouse_interaction(
556        &self,
557        layout: Layout<'_>,
558        cursor: Cursor,
559        renderer: &Renderer,
560    ) -> Interaction {
561        let interaction = self.menu.as_widget().mouse_interaction(
562            self.tree,
563            layout,
564            cursor,
565            &layout.bounds(),
566            renderer,
567        );
568
569        if interaction == Interaction::None && cursor.is_over(layout.bounds()) {
570            Interaction::Idle
571        } else {
572            interaction
573        }
574    }
575
576    fn overlay<'a>(
577        &'a mut self,
578        layout: Layout<'a>,
579        renderer: &Renderer,
580    ) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
581        self.menu.as_widget_mut().overlay(
582            self.tree,
583            layout,
584            renderer,
585            &layout.bounds(),
586            Vector::ZERO,
587        )
588    }
589}