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                    && let Some(index) = *self.hovered_option
412                    && let Some(option) = self.options.get(index)
413                {
414                    shell.publish((self.on_selected)(option.clone()));
415                    shell.capture_event();
416                }
417            }
418            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
419                if let Some(cursor_position) =
420                    cursor.position_in(layout.bounds())
421                {
422                    let text_size = self
423                        .text_size
424                        .unwrap_or_else(|| renderer.default_size());
425
426                    let option_height =
427                        f32::from(self.text_line_height.to_absolute(text_size))
428                            + self.padding.vertical();
429
430                    let new_hovered_option =
431                        (cursor_position.y / option_height) as usize;
432
433                    if *self.hovered_option != Some(new_hovered_option)
434                        && let Some(option) =
435                            self.options.get(new_hovered_option)
436                    {
437                        if let Some(on_option_hovered) = self.on_option_hovered
438                        {
439                            shell.publish(on_option_hovered(option.clone()));
440                        }
441
442                        shell.request_redraw();
443                    }
444
445                    *self.hovered_option = Some(new_hovered_option);
446                }
447            }
448            Event::Touch(touch::Event::FingerPressed { .. }) => {
449                if let Some(cursor_position) =
450                    cursor.position_in(layout.bounds())
451                {
452                    let text_size = self
453                        .text_size
454                        .unwrap_or_else(|| renderer.default_size());
455
456                    let option_height =
457                        f32::from(self.text_line_height.to_absolute(text_size))
458                            + self.padding.vertical();
459
460                    *self.hovered_option =
461                        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.is_hovered.is_some_and(|is_hovered| {
479            is_hovered != cursor.is_over(layout.bounds())
480        }) {
481            shell.request_redraw();
482        }
483    }
484
485    fn mouse_interaction(
486        &self,
487        _state: &Tree,
488        layout: Layout<'_>,
489        cursor: mouse::Cursor,
490        _viewport: &Rectangle,
491        _renderer: &Renderer,
492    ) -> mouse::Interaction {
493        let is_mouse_over = cursor.is_over(layout.bounds());
494
495        if is_mouse_over {
496            mouse::Interaction::Pointer
497        } else {
498            mouse::Interaction::default()
499        }
500    }
501
502    fn draw(
503        &self,
504        _state: &Tree,
505        renderer: &mut Renderer,
506        theme: &Theme,
507        _style: &renderer::Style,
508        layout: Layout<'_>,
509        _cursor: mouse::Cursor,
510        viewport: &Rectangle,
511    ) {
512        let style = Catalog::style(theme, self.class);
513        let bounds = layout.bounds();
514
515        let text_size =
516            self.text_size.unwrap_or_else(|| renderer.default_size());
517        let option_height =
518            f32::from(self.text_line_height.to_absolute(text_size))
519                + self.padding.vertical();
520
521        let offset = viewport.y - bounds.y;
522        let start = (offset / option_height) as usize;
523        let end = ((offset + viewport.height) / option_height).ceil() as usize;
524
525        let visible_options = &self.options[start..end.min(self.options.len())];
526
527        for (i, option) in visible_options.iter().enumerate() {
528            let i = start + i;
529            let is_selected = *self.hovered_option == Some(i);
530
531            let bounds = Rectangle {
532                x: bounds.x,
533                y: bounds.y + (option_height * i as f32),
534                width: bounds.width,
535                height: option_height,
536            };
537
538            if is_selected {
539                renderer.fill_quad(
540                    renderer::Quad {
541                        bounds: Rectangle {
542                            x: bounds.x + style.border.width,
543                            width: bounds.width - style.border.width * 2.0,
544                            ..bounds
545                        },
546                        border: border::rounded(style.border.radius),
547                        ..renderer::Quad::default()
548                    },
549                    style.selected_background,
550                );
551            }
552
553            renderer.fill_text(
554                Text {
555                    content: option.to_string(),
556                    bounds: Size::new(f32::INFINITY, bounds.height),
557                    size: text_size,
558                    line_height: self.text_line_height,
559                    font: self.font.unwrap_or_else(|| renderer.default_font()),
560                    align_x: text::Alignment::Default,
561                    align_y: alignment::Vertical::Center,
562                    shaping: self.text_shaping,
563                    wrapping: text::Wrapping::default(),
564                },
565                Point::new(bounds.x + self.padding.left, bounds.center_y()),
566                if is_selected {
567                    style.selected_text_color
568                } else {
569                    style.text_color
570                },
571                *viewport,
572            );
573        }
574    }
575}
576
577impl<'a, 'b, T, Message, Theme, Renderer>
578    From<List<'a, 'b, T, Message, Theme, Renderer>>
579    for Element<'a, Message, Theme, Renderer>
580where
581    T: ToString + Clone,
582    Message: 'a,
583    Theme: 'a + Catalog,
584    Renderer: 'a + text::Renderer,
585    'b: 'a,
586{
587    fn from(list: List<'a, 'b, T, Message, Theme, Renderer>) -> Self {
588        Element::new(list)
589    }
590}
591
592/// The appearance of a [`Menu`].
593#[derive(Debug, Clone, Copy, PartialEq)]
594pub struct Style {
595    /// The [`Background`] of the menu.
596    pub background: Background,
597    /// The [`Border`] of the menu.
598    pub border: Border,
599    /// The text [`Color`] of the menu.
600    pub text_color: Color,
601    /// The text [`Color`] of a selected option in the menu.
602    pub selected_text_color: Color,
603    /// The background [`Color`] of a selected option in the menu.
604    pub selected_background: Background,
605}
606
607/// The theme catalog of a [`Menu`].
608pub trait Catalog: scrollable::Catalog {
609    /// The item class of the [`Catalog`].
610    type Class<'a>;
611
612    /// The default class produced by the [`Catalog`].
613    fn default<'a>() -> <Self as Catalog>::Class<'a>;
614
615    /// The default class for the scrollable of the [`Menu`].
616    fn default_scrollable<'a>() -> <Self as scrollable::Catalog>::Class<'a> {
617        <Self as scrollable::Catalog>::default()
618    }
619
620    /// The [`Style`] of a class with the given status.
621    fn style(&self, class: &<Self as Catalog>::Class<'_>) -> Style;
622}
623
624/// A styling function for a [`Menu`].
625pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme) -> Style + 'a>;
626
627impl Catalog for Theme {
628    type Class<'a> = StyleFn<'a, Self>;
629
630    fn default<'a>() -> StyleFn<'a, Self> {
631        Box::new(default)
632    }
633
634    fn style(&self, class: &StyleFn<'_, Self>) -> Style {
635        class(self)
636    }
637}
638
639/// The default style of the list of a [`Menu`].
640pub fn default(theme: &Theme) -> Style {
641    let palette = theme.extended_palette();
642
643    Style {
644        background: palette.background.weak.color.into(),
645        border: Border {
646            width: 1.0,
647            radius: 0.0.into(),
648            color: palette.background.strong.color,
649        },
650        text_color: palette.background.weak.text,
651        selected_text_color: palette.primary.strong.text,
652        selected_background: palette.primary.strong.color.into(),
653    }
654}