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