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