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