Skip to main content

iced_widget/overlay/
menu.rs

1//! Build and show dropdown menus.
2use crate::core::alignment;
3use crate::core::border::{self, Border};
4use crate::core::layout::{self, Layout};
5use crate::core::mouse;
6use crate::core::overlay;
7use crate::core::renderer;
8use crate::core::text::{self, Text};
9use crate::core::touch;
10use crate::core::widget::tree::{self, Tree};
11use crate::core::window;
12use crate::core::{
13    Background, Color, Event, Font, Length, Padding, Pixels, Point, Rectangle, Shadow, Size, Theme,
14    Vector,
15};
16use crate::core::{Element, Shell, Widget};
17use crate::scrollable::{self, Scrollable};
18
19/// A list of selectable options.
20pub struct Menu<'a, 'b, T, Message, Theme = crate::Theme>
21where
22    Theme: Catalog,
23    'b: 'a,
24{
25    state: &'a mut State,
26    options: &'a [T],
27    hovered_option: &'a mut Option<usize>,
28    to_string: &'a dyn Fn(&T) -> String,
29    on_selected: Box<dyn FnMut(T) -> Message + 'a>,
30    on_option_hovered: Option<&'a dyn Fn(T) -> Message>,
31    width: f32,
32    padding: Padding,
33    text_size: Option<Pixels>,
34    line_height: Option<text::LineHeight>,
35    shaping: text::Shaping,
36    ellipsis: text::Ellipsis,
37    font: Option<Font>,
38    class: &'a <Theme as Catalog>::Class<'b>,
39}
40
41impl<'a, 'b, T, Message, Theme> Menu<'a, 'b, T, Message, Theme>
42where
43    T: Clone,
44    Message: 'a,
45    Theme: Catalog + 'a,
46    'b: 'a,
47{
48    /// Creates a new [`Menu`] with the given [`State`], a list of options,
49    /// the message to produced when an option is selected, and its [`Style`].
50    pub fn new(
51        state: &'a mut State,
52        options: &'a [T],
53        hovered_option: &'a mut Option<usize>,
54        to_string: &'a dyn Fn(&T) -> String,
55        on_selected: impl FnMut(T) -> Message + 'a,
56        on_option_hovered: Option<&'a dyn Fn(T) -> Message>,
57        class: &'a <Theme as Catalog>::Class<'b>,
58    ) -> Self {
59        Menu {
60            state,
61            options,
62            hovered_option,
63            to_string,
64            on_selected: Box::new(on_selected),
65            on_option_hovered,
66            width: 0.0,
67            padding: Padding::ZERO,
68            text_size: None,
69            line_height: None,
70            shaping: text::Shaping::default(),
71            ellipsis: text::Ellipsis::default(),
72            font: None,
73            class,
74        }
75    }
76
77    /// Sets the width of the [`Menu`].
78    pub fn width(mut self, width: f32) -> Self {
79        self.width = width;
80        self
81    }
82
83    /// Sets the [`Padding`] of the [`Menu`].
84    pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
85        self.padding = padding.into();
86        self
87    }
88
89    /// Sets the text size of the [`Menu`].
90    pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
91        self.text_size = Some(text_size.into());
92        self
93    }
94
95    /// Sets the text [`text::LineHeight`] of the [`Menu`].
96    pub fn line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
97        self.line_height = Some(line_height.into());
98        self
99    }
100
101    /// Sets the [`text::Shaping`] strategy of the [`Menu`].
102    pub fn shaping(mut self, shaping: text::Shaping) -> Self {
103        self.shaping = shaping;
104        self
105    }
106
107    /// Sets the [`text::Ellipsis`] strategy of the [`Menu`].
108    pub fn ellipsis(mut self, ellipsis: text::Ellipsis) -> Self {
109        self.ellipsis = ellipsis;
110        self
111    }
112
113    /// Sets the font of the [`Menu`].
114    pub fn font(mut self, font: impl Into<Font>) -> Self {
115        self.font = Some(font.into());
116        self
117    }
118
119    /// Turns the [`Menu`] into an overlay [`Element`] at the given target
120    /// position.
121    ///
122    /// The `target_height` will be used to display the menu either on top
123    /// of the target or under it, depending on the screen position and the
124    /// dimensions of the [`Menu`].
125    pub fn overlay<Renderer>(
126        self,
127        position: Point,
128        viewport: Rectangle,
129        target_height: f32,
130        menu_height: Length,
131    ) -> overlay::Element<'a, Message, Theme, Renderer>
132    where
133        Renderer: text::Renderer + 'a,
134    {
135        overlay::Element::new(Box::new(Overlay::new(
136            position,
137            viewport,
138            self,
139            target_height,
140            menu_height,
141        )))
142    }
143}
144
145/// The local state of a [`Menu`].
146#[derive(Debug)]
147pub struct State {
148    tree: Tree,
149}
150
151impl State {
152    /// Creates a new [`State`] for a [`Menu`].
153    pub fn new() -> Self {
154        Self {
155            tree: Tree::empty(),
156        }
157    }
158}
159
160impl Default for State {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166struct Overlay<'a, 'b, Message, Theme, Renderer>
167where
168    Theme: Catalog,
169    Renderer: text::Renderer,
170{
171    position: Point,
172    viewport: Rectangle,
173    tree: &'a mut Tree,
174    list: Scrollable<'a, Message, Theme, Renderer>,
175    width: f32,
176    target_height: f32,
177    class: &'a <Theme as Catalog>::Class<'b>,
178}
179
180impl<'a, 'b, Message, Theme, Renderer> Overlay<'a, 'b, Message, Theme, Renderer>
181where
182    Message: 'a,
183    Theme: Catalog + scrollable::Catalog + 'a,
184    Renderer: text::Renderer + 'a,
185    'b: 'a,
186{
187    pub fn new<T>(
188        position: Point,
189        viewport: Rectangle,
190        menu: Menu<'a, 'b, T, Message, Theme>,
191        target_height: f32,
192        menu_height: Length,
193    ) -> Self
194    where
195        T: Clone,
196    {
197        let Menu {
198            state,
199            options,
200            hovered_option,
201            to_string,
202            on_selected,
203            on_option_hovered,
204            width,
205            padding,
206            font,
207            text_size,
208            line_height,
209            shaping,
210            ellipsis,
211            class,
212            ..
213        } = menu;
214
215        let mut list = Scrollable::new(List {
216            options,
217            hovered_option,
218            to_string,
219            on_selected,
220            on_option_hovered,
221            font,
222            text_size,
223            line_height,
224            shaping,
225            ellipsis,
226            padding,
227            class,
228        })
229        .height(menu_height);
230
231        state.tree.diff(&mut list as &mut dyn Widget<_, _, _>);
232
233        Self {
234            position,
235            viewport,
236            tree: &mut state.tree,
237            list,
238            width,
239            target_height,
240            class,
241        }
242    }
243}
244
245impl<Message, Theme, Renderer> crate::core::Overlay<Message, Theme, Renderer>
246    for Overlay<'_, '_, Message, Theme, Renderer>
247where
248    Theme: Catalog,
249    Renderer: text::Renderer,
250{
251    fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
252        let space_below = bounds.height - (self.position.y + self.target_height);
253        let space_above = self.position.y;
254
255        let limits = layout::Limits::new(
256            Size::ZERO,
257            Size::new(
258                bounds.width - self.position.x,
259                if space_below > space_above {
260                    space_below
261                } else {
262                    space_above
263                },
264            ),
265        )
266        .width(self.width);
267
268        let node = self.list.layout(self.tree, renderer, &limits);
269        let size = node.size();
270
271        node.move_to(if space_below > space_above {
272            self.position + Vector::new(0.0, self.target_height)
273        } else {
274            self.position - Vector::new(0.0, size.height)
275        })
276    }
277
278    fn update(
279        &mut self,
280        event: &Event,
281        layout: Layout<'_>,
282        cursor: mouse::Cursor,
283        renderer: &Renderer,
284        shell: &mut Shell<'_, Message>,
285    ) {
286        let bounds = layout.bounds();
287
288        self.list
289            .update(self.tree, event, layout, cursor, renderer, shell, &bounds);
290    }
291
292    fn mouse_interaction(
293        &self,
294        layout: Layout<'_>,
295        cursor: mouse::Cursor,
296        renderer: &Renderer,
297    ) -> mouse::Interaction {
298        let interaction =
299            self.list
300                .mouse_interaction(self.tree, layout, cursor, &self.viewport, renderer);
301
302        if interaction == mouse::Interaction::None && cursor.is_over(layout.bounds()) {
303            mouse::Interaction::Idle
304        } else {
305            interaction
306        }
307    }
308
309    fn draw(
310        &self,
311        renderer: &mut Renderer,
312        theme: &Theme,
313        defaults: &renderer::Style,
314        layout: Layout<'_>,
315        cursor: mouse::Cursor,
316    ) {
317        let bounds = layout.bounds();
318
319        let style = Catalog::style(theme, self.class);
320
321        renderer.fill_quad(
322            renderer::Quad {
323                bounds,
324                border: style.border,
325                shadow: style.shadow,
326                ..renderer::Quad::default()
327            },
328            style.background,
329        );
330
331        self.list.draw(
332            self.tree, renderer, theme, defaults, layout, cursor, &bounds,
333        );
334    }
335}
336
337struct List<'a, 'b, T, Message, Theme>
338where
339    Theme: Catalog,
340{
341    options: &'a [T],
342    hovered_option: &'a mut Option<usize>,
343    to_string: &'a dyn Fn(&T) -> String,
344    on_selected: Box<dyn FnMut(T) -> Message + 'a>,
345    on_option_hovered: Option<&'a dyn Fn(T) -> Message>,
346    padding: Padding,
347    text_size: Option<Pixels>,
348    line_height: Option<text::LineHeight>,
349    shaping: text::Shaping,
350    ellipsis: text::Ellipsis,
351    font: Option<Font>,
352    class: &'a <Theme as Catalog>::Class<'b>,
353}
354
355struct ListState {
356    is_hovered: Option<bool>,
357}
358
359impl<T, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
360    for List<'_, '_, T, Message, Theme>
361where
362    T: Clone,
363    Theme: Catalog,
364    Renderer: text::Renderer,
365{
366    fn tag(&self) -> tree::Tag {
367        tree::Tag::of::<Option<bool>>()
368    }
369
370    fn state(&self) -> tree::State {
371        tree::State::new(ListState { is_hovered: None })
372    }
373
374    fn size(&self) -> Size<Length> {
375        Size {
376            width: Length::Fill,
377            height: Length::Shrink,
378        }
379    }
380
381    fn layout(
382        &mut self,
383        _tree: &mut Tree,
384        renderer: &Renderer,
385        limits: &layout::Limits,
386    ) -> layout::Node {
387        use std::f32;
388
389        let text_size = self.text_size.unwrap_or_else(|| renderer.text_size());
390        let line_height = self.line_height.unwrap_or_else(|| renderer.line_height());
391
392        let text_line_height = line_height.to_absolute(text_size);
393
394        let size = {
395            let intrinsic = Size::new(
396                0.0,
397                (f32::from(text_line_height) + self.padding.y()) * self.options.len() as f32,
398            );
399
400            limits.resolve(Length::Fill, Length::Shrink, intrinsic)
401        };
402
403        layout::Node::new(size)
404    }
405
406    fn update(
407        &mut self,
408        tree: &mut Tree,
409        event: &Event,
410        layout: Layout<'_>,
411        cursor: mouse::Cursor,
412        renderer: &Renderer,
413        shell: &mut Shell<'_, Message>,
414        _viewport: &Rectangle,
415    ) {
416        let hovered_option = self
417            .hovered_option
418            .unwrap_or_default()
419            .min(self.options.len().saturating_sub(1));
420
421        match event {
422            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
423                if cursor.is_over(layout.bounds())
424                    && let Some(option) = self.options.get(hovered_option)
425                {
426                    shell.publish((self.on_selected)(option.clone()));
427                    shell.capture_event();
428                }
429            }
430            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
431                if let Some(cursor_position) = cursor.position_in(layout.bounds()) {
432                    let text_size = self.text_size.unwrap_or_else(|| renderer.text_size());
433                    let line_height = self.line_height.unwrap_or_else(|| renderer.line_height());
434
435                    let option_height =
436                        f32::from(line_height.to_absolute(text_size)) + self.padding.y();
437
438                    let new_hovered_option = (cursor_position.y / option_height) as usize;
439
440                    if hovered_option != new_hovered_option
441                        && let Some(option) = self.options.get(new_hovered_option)
442                    {
443                        if let Some(on_option_hovered) = self.on_option_hovered {
444                            shell.publish(on_option_hovered(option.clone()));
445                        }
446
447                        shell.request_redraw();
448                    }
449
450                    *self.hovered_option = Some(new_hovered_option);
451                }
452            }
453            Event::Touch(touch::Event::FingerPressed { .. }) => {
454                if let Some(cursor_position) = cursor.position_in(layout.bounds()) {
455                    let text_size = self.text_size.unwrap_or_else(|| renderer.text_size());
456                    let line_height = self.line_height.unwrap_or_else(|| renderer.line_height());
457
458                    let option_height =
459                        f32::from(line_height.to_absolute(text_size)) + self.padding.y();
460
461                    *self.hovered_option = Some((cursor_position.y / option_height) as usize);
462
463                    if let Some(index) = *self.hovered_option
464                        && let Some(option) = self.options.get(index)
465                    {
466                        shell.publish((self.on_selected)(option.clone()));
467                        shell.capture_event();
468                    }
469                }
470            }
471            _ => {}
472        }
473
474        let state = tree.state.downcast_mut::<ListState>();
475
476        if let Event::Window(window::Event::RedrawRequested(_now)) = event {
477            state.is_hovered = Some(cursor.is_over(layout.bounds()));
478        } else if state
479            .is_hovered
480            .is_some_and(|is_hovered| is_hovered != cursor.is_over(layout.bounds()))
481        {
482            shell.request_redraw();
483        }
484    }
485
486    fn mouse_interaction(
487        &self,
488        _tree: &Tree,
489        layout: Layout<'_>,
490        cursor: mouse::Cursor,
491        _viewport: &Rectangle,
492        _renderer: &Renderer,
493    ) -> mouse::Interaction {
494        let is_mouse_over = cursor.is_over(layout.bounds());
495
496        if is_mouse_over {
497            mouse::Interaction::Pointer
498        } else {
499            mouse::Interaction::default()
500        }
501    }
502
503    fn draw(
504        &self,
505        _tree: &Tree,
506        renderer: &mut Renderer,
507        theme: &Theme,
508        _style: &renderer::Style,
509        layout: Layout<'_>,
510        _cursor: mouse::Cursor,
511        viewport: &Rectangle,
512    ) {
513        let style = Catalog::style(theme, self.class);
514        let bounds = layout.bounds();
515
516        let text_size = self.text_size.unwrap_or_else(|| renderer.text_size());
517        let line_height = self.line_height.unwrap_or_else(|| renderer.line_height());
518        let option_height = f32::from(line_height.to_absolute(text_size)) + self.padding.y();
519
520        let offset = viewport.y - bounds.y;
521        let start = (offset / option_height) as usize;
522        let end = ((offset + viewport.height) / option_height).ceil() as usize;
523
524        let visible_options = &self.options[start..end.min(self.options.len())];
525        let hovered_option = self
526            .hovered_option
527            .map(|index| index.min(self.options.len().saturating_sub(1)));
528
529        for (i, option) in visible_options.iter().enumerate() {
530            let i = start + i;
531            let is_selected = hovered_option == Some(i);
532
533            let bounds = Rectangle {
534                x: bounds.x,
535                y: bounds.y + (option_height * i as f32),
536                width: bounds.width,
537                height: option_height,
538            };
539
540            if is_selected {
541                renderer.fill_quad(
542                    renderer::Quad {
543                        bounds: Rectangle {
544                            x: bounds.x + style.border.width,
545                            width: bounds.width - style.border.width * 2.0,
546                            ..bounds
547                        },
548                        border: border::rounded(style.border.radius),
549                        ..renderer::Quad::default()
550                    },
551                    style.selected_background,
552                );
553            }
554
555            renderer.fill_text(
556                Text {
557                    content: (self.to_string)(option),
558                    bounds: Size::new(bounds.width - self.padding.x(), bounds.height),
559                    size: text_size,
560                    line_height,
561                    font: self.font.unwrap_or_else(|| renderer.font()),
562                    align_x: text::Alignment::Default,
563                    align_y: alignment::Vertical::Center,
564                    shaping: self.shaping,
565                    wrapping: text::Wrapping::None,
566                    ellipsis: self.ellipsis,
567                    hint_factor: renderer.hint_factor(),
568                },
569                Point::new(bounds.x + self.padding.left, bounds.center_y()),
570                if is_selected {
571                    style.selected_text_color
572                } else {
573                    style.text_color
574                },
575                *viewport,
576            );
577        }
578    }
579}
580
581impl<'a, 'b, T, Message, Theme, Renderer> From<List<'a, 'b, T, Message, Theme>>
582    for Element<'a, Message, Theme, Renderer>
583where
584    T: Clone,
585    Message: 'a,
586    Theme: 'a + Catalog,
587    Renderer: 'a + text::Renderer,
588    'b: 'a,
589{
590    fn from(list: List<'a, 'b, T, Message, Theme>) -> Self {
591        Element::new(list)
592    }
593}
594
595/// The appearance of a [`Menu`].
596#[derive(Debug, Clone, Copy, PartialEq)]
597pub struct Style {
598    /// The [`Background`] of the menu.
599    pub background: Background,
600    /// The [`Border`] of the menu.
601    pub border: Border,
602    /// The text [`Color`] of the menu.
603    pub text_color: Color,
604    /// The text [`Color`] of a selected option in the menu.
605    pub selected_text_color: Color,
606    /// The background [`Color`] of a selected option in the menu.
607    pub selected_background: Background,
608    /// The [`Shadow`] of the menu.
609    pub shadow: Shadow,
610}
611
612/// The theme catalog of a [`Menu`].
613pub trait Catalog: scrollable::Catalog {
614    /// The item class of the [`Catalog`].
615    type Class<'a>;
616
617    /// The default class produced by the [`Catalog`].
618    fn default<'a>() -> <Self as Catalog>::Class<'a>;
619
620    /// The default class for the scrollable of the [`Menu`].
621    fn default_scrollable<'a>() -> <Self as scrollable::Catalog>::Class<'a> {
622        <Self as scrollable::Catalog>::default()
623    }
624
625    /// The [`Style`] of a class with the given status.
626    fn style(&self, class: &<Self as Catalog>::Class<'_>) -> Style;
627}
628
629/// A styling function for a [`Menu`].
630pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme) -> Style + 'a>;
631
632impl Catalog for Theme {
633    type Class<'a> = StyleFn<'a, Self>;
634
635    fn default<'a>() -> StyleFn<'a, Self> {
636        Box::new(default)
637    }
638
639    fn style(&self, class: &StyleFn<'_, Self>) -> Style {
640        class(self)
641    }
642}
643
644/// The default style of the list of a [`Menu`].
645pub fn default(theme: &Theme) -> Style {
646    let palette = theme.palette();
647
648    Style {
649        background: palette.background.weak.color.into(),
650        border: Border {
651            width: 1.0,
652            radius: 0.0.into(),
653            color: palette.background.strong.color,
654        },
655        text_color: palette.background.weak.text,
656        selected_text_color: palette.primary.strong.text,
657        selected_background: palette.primary.strong.color.into(),
658        shadow: Shadow::default(),
659    }
660}