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        target_height: f32,
131    ) -> overlay::Element<'a, Message, Theme, Renderer> {
132        overlay::Element::new(Box::new(Overlay::new(
133            position,
134            self,
135            target_height,
136        )))
137    }
138}
139
140/// The local state of a [`Menu`].
141#[derive(Debug)]
142pub struct State {
143    tree: Tree,
144}
145
146impl State {
147    /// Creates a new [`State`] for a [`Menu`].
148    pub fn new() -> Self {
149        Self {
150            tree: Tree::empty(),
151        }
152    }
153}
154
155impl Default for State {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161struct Overlay<'a, 'b, Message, Theme, Renderer>
162where
163    Theme: Catalog,
164    Renderer: crate::core::Renderer,
165{
166    position: Point,
167    state: &'a mut Tree,
168    list: Scrollable<'a, Message, Theme, Renderer>,
169    width: f32,
170    target_height: f32,
171    class: &'a <Theme as Catalog>::Class<'b>,
172}
173
174impl<'a, 'b, Message, Theme, Renderer> Overlay<'a, 'b, Message, Theme, Renderer>
175where
176    Message: 'a,
177    Theme: Catalog + scrollable::Catalog + 'a,
178    Renderer: text::Renderer + 'a,
179    'b: 'a,
180{
181    pub fn new<T>(
182        position: Point,
183        menu: Menu<'a, 'b, T, Message, Theme, Renderer>,
184        target_height: f32,
185    ) -> Self
186    where
187        T: Clone + ToString,
188    {
189        let Menu {
190            state,
191            options,
192            hovered_option,
193            on_selected,
194            on_option_hovered,
195            width,
196            padding,
197            font,
198            text_size,
199            text_line_height,
200            text_shaping,
201            class,
202        } = menu;
203
204        let list = Scrollable::new(List {
205            options,
206            hovered_option,
207            on_selected,
208            on_option_hovered,
209            font,
210            text_size,
211            text_line_height,
212            text_shaping,
213            padding,
214            class,
215        });
216
217        state.tree.diff(&list as &dyn Widget<_, _, _>);
218
219        Self {
220            position,
221            state: &mut state.tree,
222            list,
223            width,
224            target_height,
225            class,
226        }
227    }
228}
229
230impl<Message, Theme, Renderer> crate::core::Overlay<Message, Theme, Renderer>
231    for Overlay<'_, '_, Message, Theme, Renderer>
232where
233    Theme: Catalog,
234    Renderer: text::Renderer,
235{
236    fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
237        let space_below =
238            bounds.height - (self.position.y + self.target_height);
239        let space_above = self.position.y;
240
241        let limits = layout::Limits::new(
242            Size::ZERO,
243            Size::new(
244                bounds.width - self.position.x,
245                if space_below > space_above {
246                    space_below
247                } else {
248                    space_above
249                },
250            ),
251        )
252        .width(self.width);
253
254        let node = self.list.layout(self.state, renderer, &limits);
255        let size = node.size();
256
257        node.move_to(if space_below > space_above {
258            self.position + Vector::new(0.0, self.target_height)
259        } else {
260            self.position - Vector::new(0.0, size.height)
261        })
262    }
263
264    fn update(
265        &mut self,
266        event: &Event,
267        layout: Layout<'_>,
268        cursor: mouse::Cursor,
269        renderer: &Renderer,
270        clipboard: &mut dyn Clipboard,
271        shell: &mut Shell<'_, Message>,
272    ) {
273        let bounds = layout.bounds();
274
275        self.list.update(
276            self.state, event, layout, cursor, renderer, clipboard, shell,
277            &bounds,
278        );
279    }
280
281    fn mouse_interaction(
282        &self,
283        layout: Layout<'_>,
284        cursor: mouse::Cursor,
285        viewport: &Rectangle,
286        renderer: &Renderer,
287    ) -> mouse::Interaction {
288        self.list
289            .mouse_interaction(self.state, layout, cursor, viewport, renderer)
290    }
291
292    fn draw(
293        &self,
294        renderer: &mut Renderer,
295        theme: &Theme,
296        defaults: &renderer::Style,
297        layout: Layout<'_>,
298        cursor: mouse::Cursor,
299    ) {
300        let bounds = layout.bounds();
301
302        let style = Catalog::style(theme, self.class);
303
304        renderer.fill_quad(
305            renderer::Quad {
306                bounds,
307                border: style.border,
308                ..renderer::Quad::default()
309            },
310            style.background,
311        );
312
313        self.list.draw(
314            self.state, renderer, theme, defaults, layout, cursor, &bounds,
315        );
316    }
317}
318
319struct List<'a, 'b, T, Message, Theme, Renderer>
320where
321    Theme: Catalog,
322    Renderer: text::Renderer,
323{
324    options: &'a [T],
325    hovered_option: &'a mut Option<usize>,
326    on_selected: Box<dyn FnMut(T) -> Message + 'a>,
327    on_option_hovered: Option<&'a dyn Fn(T) -> Message>,
328    padding: Padding,
329    text_size: Option<Pixels>,
330    text_line_height: text::LineHeight,
331    text_shaping: text::Shaping,
332    font: Option<Renderer::Font>,
333    class: &'a <Theme as Catalog>::Class<'b>,
334}
335
336struct ListState {
337    is_hovered: Option<bool>,
338}
339
340impl<T, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
341    for List<'_, '_, T, Message, Theme, Renderer>
342where
343    T: Clone + ToString,
344    Theme: Catalog,
345    Renderer: text::Renderer,
346{
347    fn tag(&self) -> tree::Tag {
348        tree::Tag::of::<Option<bool>>()
349    }
350
351    fn state(&self) -> tree::State {
352        tree::State::new(ListState { is_hovered: None })
353    }
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 update(
389        &mut self,
390        tree: &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    ) {
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                            shell.capture_event();
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 *self.hovered_option != Some(new_hovered_option) {
426                        if let Some(option) =
427                            self.options.get(new_hovered_option)
428                        {
429                            if let Some(on_option_hovered) =
430                                self.on_option_hovered
431                            {
432                                shell
433                                    .publish(on_option_hovered(option.clone()));
434                            }
435
436                            shell.request_redraw();
437                        }
438                    }
439
440                    *self.hovered_option = Some(new_hovered_option);
441                }
442            }
443            Event::Touch(touch::Event::FingerPressed { .. }) => {
444                if let Some(cursor_position) =
445                    cursor.position_in(layout.bounds())
446                {
447                    let text_size = self
448                        .text_size
449                        .unwrap_or_else(|| renderer.default_size());
450
451                    let option_height =
452                        f32::from(self.text_line_height.to_absolute(text_size))
453                            + self.padding.vertical();
454
455                    *self.hovered_option =
456                        Some((cursor_position.y / option_height) as usize);
457
458                    if let Some(index) = *self.hovered_option {
459                        if let Some(option) = self.options.get(index) {
460                            shell.publish((self.on_selected)(option.clone()));
461                            shell.capture_event();
462                        }
463                    }
464                }
465            }
466            _ => {}
467        }
468
469        let state = tree.state.downcast_mut::<ListState>();
470
471        if let Event::Window(window::Event::RedrawRequested(_now)) = event {
472            state.is_hovered = Some(cursor.is_over(layout.bounds()));
473        } else if state.is_hovered.is_some_and(|is_hovered| {
474            is_hovered != cursor.is_over(layout.bounds())
475        }) {
476            shell.request_redraw();
477        }
478    }
479
480    fn mouse_interaction(
481        &self,
482        _state: &Tree,
483        layout: Layout<'_>,
484        cursor: mouse::Cursor,
485        _viewport: &Rectangle,
486        _renderer: &Renderer,
487    ) -> mouse::Interaction {
488        let is_mouse_over = cursor.is_over(layout.bounds());
489
490        if is_mouse_over {
491            mouse::Interaction::Pointer
492        } else {
493            mouse::Interaction::default()
494        }
495    }
496
497    fn draw(
498        &self,
499        _state: &Tree,
500        renderer: &mut Renderer,
501        theme: &Theme,
502        _style: &renderer::Style,
503        layout: Layout<'_>,
504        _cursor: mouse::Cursor,
505        viewport: &Rectangle,
506    ) {
507        let style = Catalog::style(theme, self.class);
508        let bounds = layout.bounds();
509
510        let text_size =
511            self.text_size.unwrap_or_else(|| renderer.default_size());
512        let option_height =
513            f32::from(self.text_line_height.to_absolute(text_size))
514                + self.padding.vertical();
515
516        let offset = viewport.y - bounds.y;
517        let start = (offset / option_height) as usize;
518        let end = ((offset + viewport.height) / option_height).ceil() as usize;
519
520        let visible_options = &self.options[start..end.min(self.options.len())];
521
522        for (i, option) in visible_options.iter().enumerate() {
523            let i = start + i;
524            let is_selected = *self.hovered_option == Some(i);
525
526            let bounds = Rectangle {
527                x: bounds.x,
528                y: bounds.y + (option_height * i as f32),
529                width: bounds.width,
530                height: option_height,
531            };
532
533            if is_selected {
534                renderer.fill_quad(
535                    renderer::Quad {
536                        bounds: Rectangle {
537                            x: bounds.x + style.border.width,
538                            width: bounds.width - style.border.width * 2.0,
539                            ..bounds
540                        },
541                        border: border::rounded(style.border.radius),
542                        ..renderer::Quad::default()
543                    },
544                    style.selected_background,
545                );
546            }
547
548            renderer.fill_text(
549                Text {
550                    content: option.to_string(),
551                    bounds: Size::new(f32::INFINITY, bounds.height),
552                    size: text_size,
553                    line_height: self.text_line_height,
554                    font: self.font.unwrap_or_else(|| renderer.default_font()),
555                    align_x: text::Alignment::Default,
556                    align_y: alignment::Vertical::Center,
557                    shaping: self.text_shaping,
558                    wrapping: text::Wrapping::default(),
559                },
560                Point::new(bounds.x + self.padding.left, bounds.center_y()),
561                if is_selected {
562                    style.selected_text_color
563                } else {
564                    style.text_color
565                },
566                *viewport,
567            );
568        }
569    }
570}
571
572impl<'a, 'b, T, Message, Theme, Renderer>
573    From<List<'a, 'b, T, Message, Theme, Renderer>>
574    for Element<'a, Message, Theme, Renderer>
575where
576    T: ToString + Clone,
577    Message: 'a,
578    Theme: 'a + Catalog,
579    Renderer: 'a + text::Renderer,
580    'b: 'a,
581{
582    fn from(list: List<'a, 'b, T, Message, Theme, Renderer>) -> Self {
583        Element::new(list)
584    }
585}
586
587/// The appearance of a [`Menu`].
588#[derive(Debug, Clone, Copy, PartialEq)]
589pub struct Style {
590    /// The [`Background`] of the menu.
591    pub background: Background,
592    /// The [`Border`] of the menu.
593    pub border: Border,
594    /// The text [`Color`] of the menu.
595    pub text_color: Color,
596    /// The text [`Color`] of a selected option in the menu.
597    pub selected_text_color: Color,
598    /// The background [`Color`] of a selected option in the menu.
599    pub selected_background: Background,
600}
601
602/// The theme catalog of a [`Menu`].
603pub trait Catalog: scrollable::Catalog {
604    /// The item class of the [`Catalog`].
605    type Class<'a>;
606
607    /// The default class produced by the [`Catalog`].
608    fn default<'a>() -> <Self as Catalog>::Class<'a>;
609
610    /// The default class for the scrollable of the [`Menu`].
611    fn default_scrollable<'a>() -> <Self as scrollable::Catalog>::Class<'a> {
612        <Self as scrollable::Catalog>::default()
613    }
614
615    /// The [`Style`] of a class with the given status.
616    fn style(&self, class: &<Self as Catalog>::Class<'_>) -> Style;
617}
618
619/// A styling function for a [`Menu`].
620pub type StyleFn<'a, Theme> = Box<dyn Fn(&Theme) -> Style + 'a>;
621
622impl Catalog for Theme {
623    type Class<'a> = StyleFn<'a, Self>;
624
625    fn default<'a>() -> StyleFn<'a, Self> {
626        Box::new(default)
627    }
628
629    fn style(&self, class: &StyleFn<'_, Self>) -> Style {
630        class(self)
631    }
632}
633
634/// The default style of the list of a [`Menu`].
635pub fn default(theme: &Theme) -> Style {
636    let palette = theme.extended_palette();
637
638    Style {
639        background: palette.background.weak.color.into(),
640        border: Border {
641            width: 1.0,
642            radius: 0.0.into(),
643            color: palette.background.strong.color,
644        },
645        text_color: palette.background.weak.text,
646        selected_text_color: palette.primary.strong.text,
647        selected_background: palette.primary.strong.color.into(),
648    }
649}