Skip to main content

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