iced_widget/overlay/
menu.rs

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