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