Skip to main content

cosmic/widget/dropdown/multi/
menu.rs

1use super::Model;
2pub use crate::widget::dropdown::menu::{Appearance, StyleSheet};
3
4use crate::widget::Container;
5use iced_core::event::{self, Event};
6use iced_core::layout::{self, Layout};
7use iced_core::text::{self, Text};
8use iced_core::widget::Tree;
9use iced_core::{
10    Border, Clipboard, Element, Length, Padding, Pixels, Point, Rectangle, Renderer, Shadow, Shell,
11    Size, Vector, Widget, alignment, mouse, overlay, renderer, svg, touch,
12};
13use iced_widget::scrollable::Scrollable;
14
15/// A dropdown menu with multiple lists.
16#[must_use]
17pub struct Menu<'a, S, Item, Message>
18where
19    S: AsRef<str>,
20{
21    state: &'a mut State,
22    options: &'a Model<S, Item>,
23    hovered_option: &'a mut Option<Item>,
24    selected_option: Option<&'a Item>,
25    on_selected: Box<dyn FnMut(Item) -> Message + 'a>,
26    on_option_hovered: Option<&'a dyn Fn(Item) -> Message>,
27    width: f32,
28    padding: Padding,
29    text_size: Option<f32>,
30    text_line_height: text::LineHeight,
31    style: (),
32}
33
34impl<'a, S, Item, Message: 'a> Menu<'a, S, Item, Message>
35where
36    S: AsRef<str>,
37    Item: Clone + PartialEq,
38{
39    /// Creates a new [`Menu`] with the given [`State`], a list of options, and
40    /// the message to produced when an option is selected.
41    pub(super) fn new(
42        state: &'a mut State,
43        options: &'a Model<S, Item>,
44        hovered_option: &'a mut Option<Item>,
45        selected_option: Option<&'a Item>,
46        on_selected: impl FnMut(Item) -> Message + 'a,
47        on_option_hovered: Option<&'a dyn Fn(Item) -> Message>,
48    ) -> Self {
49        Menu {
50            state,
51            options,
52            hovered_option,
53            selected_option,
54            on_selected: Box::new(on_selected),
55            on_option_hovered,
56            width: 0.0,
57            padding: Padding::ZERO,
58            text_size: None,
59            text_line_height: text::LineHeight::Absolute(Pixels::from(16.0)),
60            style: Default::default(),
61        }
62    }
63
64    /// Sets the width of the [`Menu`].
65    pub fn width(mut self, width: f32) -> Self {
66        self.width = width;
67        self
68    }
69
70    /// Sets the [`Padding`] of the [`Menu`].
71    pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
72        self.padding = padding.into();
73        self
74    }
75
76    /// Sets the text size of the [`Menu`].
77    pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
78        self.text_size = Some(text_size.into().0);
79        self
80    }
81
82    /// Sets the text [`LineHeight`] of the [`Menu`].
83    pub fn text_line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
84        self.text_line_height = line_height.into();
85        self
86    }
87
88    /// Turns the [`Menu`] into an overlay [`Element`] at the given target
89    /// position.
90    ///
91    /// The `target_height` will be used to display the menu either on top
92    /// of the target or under it, depending on the screen position and the
93    /// dimensions of the [`Menu`].
94    #[must_use]
95    pub fn overlay(
96        self,
97        position: Point,
98        target_height: f32,
99    ) -> overlay::Element<'a, Message, crate::Theme, crate::Renderer> {
100        overlay::Element::new(Box::new(Overlay::new(self, target_height, position)))
101    }
102}
103
104/// The local state of a [`Menu`].
105#[must_use]
106#[derive(Debug)]
107pub(super) struct State {
108    tree: Tree,
109}
110
111impl State {
112    /// Creates a new [`State`] for a [`Menu`].
113    pub fn new() -> Self {
114        Self {
115            tree: Tree::empty(),
116        }
117    }
118}
119
120impl Default for State {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126struct Overlay<'a, Message> {
127    state: &'a mut Tree,
128    container: Container<'a, Message, crate::Theme, crate::Renderer>,
129    width: f32,
130    target_height: f32,
131    style: (),
132    position: Point,
133}
134
135impl<'a, Message: 'a> Overlay<'a, Message> {
136    pub fn new<S: AsRef<str>, Item: Clone + PartialEq>(
137        menu: Menu<'a, S, Item, Message>,
138        target_height: f32,
139        position: Point,
140    ) -> Self {
141        let Menu {
142            state,
143            options,
144            hovered_option,
145            selected_option,
146            on_selected,
147            on_option_hovered,
148            width,
149            padding,
150            text_size,
151            text_line_height,
152            style,
153        } = menu;
154
155        let mut container = Container::new(Scrollable::new(
156            Container::new(InnerList {
157                options,
158                hovered_option,
159                selected_option,
160                on_selected,
161                on_option_hovered,
162                padding,
163                text_size,
164                text_line_height,
165            })
166            .padding(padding),
167        ))
168        .class(crate::style::Container::Dropdown);
169
170        state.tree.diff(&mut container as &mut dyn Widget<_, _, _>);
171
172        Self {
173            state: &mut state.tree,
174            container,
175            width,
176            target_height,
177            style,
178            position,
179        }
180    }
181}
182
183impl<Message> iced_core::Overlay<Message, crate::Theme, crate::Renderer> for Overlay<'_, Message> {
184    fn layout(&mut self, renderer: &crate::Renderer, bounds: Size) -> layout::Node {
185        let position = self.position;
186        let space_below = bounds.height - (position.y + self.target_height);
187        let space_above = position.y;
188
189        let limits = layout::Limits::new(
190            Size::ZERO,
191            Size::new(
192                bounds.width - position.x,
193                if space_below > space_above {
194                    space_below
195                } else {
196                    space_above
197                },
198            ),
199        )
200        .width(self.width);
201
202        let node = self.container.layout(self.state, renderer, &limits);
203
204        let node_size = node.size();
205        node.move_to(if space_below > space_above {
206            position + Vector::new(0.0, self.target_height)
207        } else {
208            position - Vector::new(0.0, node_size.height)
209        })
210    }
211
212    fn update(
213        &mut self,
214        event: &Event,
215        layout: Layout<'_>,
216        cursor: mouse::Cursor,
217        renderer: &crate::Renderer,
218        clipboard: &mut dyn Clipboard,
219        shell: &mut Shell<'_, Message>,
220    ) {
221        let bounds = layout.bounds();
222
223        self.container.update(
224            self.state, event, layout, cursor, renderer, clipboard, shell, &bounds,
225        )
226    }
227
228    fn mouse_interaction(
229        &self,
230        layout: Layout<'_>,
231        cursor: mouse::Cursor,
232        renderer: &crate::Renderer,
233    ) -> mouse::Interaction {
234        self.container
235            .mouse_interaction(self.state, layout, cursor, &layout.bounds(), renderer)
236    }
237
238    fn draw(
239        &self,
240        renderer: &mut crate::Renderer,
241        theme: &crate::Theme,
242        style: &renderer::Style,
243        layout: Layout<'_>,
244        cursor: mouse::Cursor,
245    ) {
246        let appearance = theme.appearance(&self.style);
247        let bounds = layout.bounds();
248
249        renderer.fill_quad(
250            renderer::Quad {
251                bounds,
252                border: Border {
253                    width: appearance.border_width,
254                    color: appearance.border_color,
255                    radius: appearance.border_radius,
256                },
257                shadow: Shadow::default(),
258                snap: true,
259            },
260            appearance.background,
261        );
262
263        self.container
264            .draw(self.state, renderer, theme, style, layout, cursor, &bounds);
265    }
266}
267
268struct InnerList<'a, S, Item, Message> {
269    options: &'a Model<S, Item>,
270    hovered_option: &'a mut Option<Item>,
271    selected_option: Option<&'a Item>,
272    on_selected: Box<dyn FnMut(Item) -> Message + 'a>,
273    on_option_hovered: Option<&'a dyn Fn(Item) -> Message>,
274    padding: Padding,
275    text_size: Option<f32>,
276    text_line_height: text::LineHeight,
277}
278
279impl<S, Item, Message> Widget<Message, crate::Theme, crate::Renderer>
280    for InnerList<'_, S, Item, Message>
281where
282    S: AsRef<str>,
283    Item: Clone + PartialEq,
284{
285    fn size(&self) -> Size<Length> {
286        Size::new(Length::Fill, Length::Shrink)
287    }
288
289    fn layout(
290        &mut self,
291        _tree: &mut Tree,
292        renderer: &crate::Renderer,
293        limits: &layout::Limits,
294    ) -> layout::Node {
295        use std::f32;
296
297        let limits = limits.width(Length::Fill).height(Length::Shrink);
298        let text_size = self
299            .text_size
300            .unwrap_or_else(|| text::Renderer::default_size(renderer).0);
301
302        let text_line_height = self.text_line_height.to_absolute(Pixels(text_size));
303
304        let lists = self.options.lists.len();
305        let (descriptions, options) = self.options.lists.iter().fold((0, 0), |acc, l| {
306            (
307                acc.0 + i32::from(l.description.is_some()),
308                acc.1 + l.options.len(),
309            )
310        });
311
312        let vertical_padding = self.padding.y();
313        let text_line_height = f32::from(text_line_height);
314
315        let size = {
316            #[allow(clippy::cast_precision_loss)]
317            let intrinsic = Size::new(0.0, {
318                let text = vertical_padding + text_line_height;
319                let separators = ((vertical_padding / 2.0) + 1.0) * (lists - 1) as f32;
320                let descriptions = (text + 4.0) * descriptions as f32;
321                let options = text * options as f32;
322                separators + descriptions + options
323            });
324
325            limits.resolve(Length::Fill, Length::Shrink, intrinsic)
326        };
327
328        layout::Node::new(size)
329    }
330
331    fn update(
332        &mut self,
333        _state: &mut Tree,
334        event: &Event,
335        layout: Layout<'_>,
336        cursor: mouse::Cursor,
337        renderer: &crate::Renderer,
338        _clipboard: &mut dyn Clipboard,
339        shell: &mut Shell<'_, Message>,
340        _viewport: &Rectangle,
341    ) {
342        let bounds = layout.bounds();
343
344        match event {
345            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
346                if cursor.is_over(bounds) {
347                    if let Some(item) = self.hovered_option.as_ref() {
348                        shell.publish((self.on_selected)(item.clone()));
349                        shell.capture_event();
350                        return;
351                    }
352                }
353            }
354            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
355                if let Some(cursor_position) = cursor.position_in(bounds) {
356                    let text_size = self
357                        .text_size
358                        .unwrap_or_else(|| text::Renderer::default_size(renderer).0);
359
360                    let text_line_height =
361                        f32::from(self.text_line_height.to_absolute(Pixels(text_size)));
362
363                    let heights = self
364                        .options
365                        .element_heights(self.padding.y(), text_line_height);
366
367                    let mut current_offset = 0.0;
368
369                    let previous_hover_option = self.hovered_option.clone();
370                    *self.hovered_option = None;
371
372                    for (element, elem_height) in self.options.elements().zip(heights) {
373                        let bounds = Rectangle {
374                            x: 0.0,
375                            y: 0.0 + current_offset,
376                            width: bounds.width,
377                            height: elem_height,
378                        };
379
380                        if bounds.contains(cursor_position) {
381                            if let OptionElement::Option((_, item)) = element {
382                                *self.hovered_option = Some(item.clone());
383                                if previous_hover_option.as_ref() != Some(item) {
384                                    if let Some(on_option_hovered) = self.on_option_hovered {
385                                        shell.publish(on_option_hovered(item.clone()));
386                                    }
387                                }
388                            }
389
390                            break;
391                        }
392                        current_offset += elem_height;
393                    }
394
395                    if *self.hovered_option != previous_hover_option {
396                        shell.request_redraw();
397                    }
398                } else if self.hovered_option.is_some() {
399                    *self.hovered_option = None;
400                    shell.request_redraw();
401                }
402            }
403            Event::Mouse(mouse::Event::CursorLeft) => {
404                if self.hovered_option.is_some() {
405                    *self.hovered_option = None;
406                    shell.request_redraw();
407                }
408            }
409            Event::Touch(touch::Event::FingerPressed { .. }) => {
410                if let Some(cursor_position) = cursor.position_in(bounds) {
411                    let text_size = self
412                        .text_size
413                        .unwrap_or_else(|| text::Renderer::default_size(renderer).0);
414
415                    let text_line_height =
416                        f32::from(self.text_line_height.to_absolute(Pixels(text_size)));
417
418                    let heights = self
419                        .options
420                        .element_heights(self.padding.y(), text_line_height);
421
422                    let mut current_offset = 0.0;
423
424                    let previous_hover_option = self.hovered_option.take();
425
426                    for (element, elem_height) in self.options.elements().zip(heights) {
427                        let bounds = Rectangle {
428                            x: 0.0,
429                            y: 0.0 + current_offset,
430                            width: bounds.width,
431                            height: elem_height,
432                        };
433
434                        if bounds.contains(cursor_position) {
435                            *self.hovered_option = if let OptionElement::Option((_, item)) = element
436                            {
437                                if previous_hover_option.as_ref() == Some(item) {
438                                    previous_hover_option
439                                } else {
440                                    Some(item.clone())
441                                }
442                            } else {
443                                None
444                            };
445
446                            if let Some(item) = self.hovered_option {
447                                shell.publish((self.on_selected)(item.clone()));
448                            }
449
450                            break;
451                        }
452                        current_offset += elem_height;
453                    }
454                }
455            }
456            _ => {}
457        }
458    }
459
460    fn mouse_interaction(
461        &self,
462        _state: &Tree,
463        layout: Layout<'_>,
464        cursor: mouse::Cursor,
465        _viewport: &Rectangle,
466        _renderer: &crate::Renderer,
467    ) -> mouse::Interaction {
468        let is_mouse_over = cursor.is_over(layout.bounds());
469
470        if is_mouse_over {
471            mouse::Interaction::Pointer
472        } else {
473            mouse::Interaction::default()
474        }
475    }
476
477    #[allow(clippy::too_many_lines)]
478    fn draw(
479        &self,
480        _state: &Tree,
481        renderer: &mut crate::Renderer,
482        theme: &crate::Theme,
483        style: &renderer::Style,
484        layout: Layout<'_>,
485        cursor: mouse::Cursor,
486        viewport: &Rectangle,
487    ) {
488        let appearance = theme.appearance(&());
489        let bounds = layout.bounds();
490
491        let text_size = self
492            .text_size
493            .unwrap_or_else(|| text::Renderer::default_size(renderer).0);
494
495        let offset = viewport.y - bounds.y;
496
497        let text_line_height = f32::from(self.text_line_height.to_absolute(Pixels(text_size)));
498
499        let visible_options = self.options.visible_options(
500            self.padding.y(),
501            text_line_height,
502            offset,
503            viewport.height,
504        );
505
506        let mut current_offset = 0.0;
507
508        for (elem, elem_height) in visible_options {
509            let mut bounds = Rectangle {
510                x: bounds.x,
511                y: bounds.y + current_offset,
512                width: bounds.width,
513                height: elem_height,
514            };
515
516            current_offset += elem_height;
517
518            match elem {
519                OptionElement::Option((option, item)) => {
520                    let (color, font) = if self.selected_option.as_ref() == Some(&item) {
521                        let item_x = bounds.x + appearance.border_width;
522                        let item_width = appearance.border_width.mul_add(-2.0, bounds.width);
523
524                        bounds = Rectangle {
525                            x: item_x,
526                            width: item_width,
527                            ..bounds
528                        };
529
530                        renderer.fill_quad(
531                            renderer::Quad {
532                                bounds,
533                                border: Border {
534                                    radius: appearance.border_radius,
535                                    ..Default::default()
536                                },
537                                shadow: Shadow::default(),
538                                snap: true,
539                            },
540                            appearance.selected_background,
541                        );
542
543                        let svg_bounds = Rectangle {
544                            x: item_x + item_width - 16.0 - 8.0,
545                            y: bounds.y + (bounds.height / 2.0 - 8.0),
546                            width: 16.0,
547                            height: 16.0,
548                        };
549
550                        let svg_handle =
551                            svg::Svg::new(crate::widget::common::object_select().clone())
552                                .color(appearance.selected_text_color)
553                                .border_radius(appearance.border_radius);
554                        svg::Renderer::draw_svg(renderer, svg_handle, svg_bounds, svg_bounds);
555
556                        (appearance.selected_text_color, crate::font::semibold())
557                    } else if self.hovered_option.as_ref() == Some(item) {
558                        let item_x = bounds.x + appearance.border_width;
559                        let item_width = appearance.border_width.mul_add(-2.0, bounds.width);
560
561                        bounds = Rectangle {
562                            x: item_x,
563                            width: item_width,
564                            ..bounds
565                        };
566
567                        renderer.fill_quad(
568                            renderer::Quad {
569                                bounds,
570                                border: Border {
571                                    radius: appearance.border_radius,
572                                    ..Default::default()
573                                },
574                                shadow: Shadow::default(),
575                                snap: true,
576                            },
577                            appearance.hovered_background,
578                        );
579
580                        (appearance.hovered_text_color, crate::font::default())
581                    } else {
582                        (appearance.text_color, crate::font::default())
583                    };
584
585                    let bounds = Rectangle {
586                        x: bounds.x + self.padding.left,
587                        // TODO: Figure out why it's offset by 8 pixels
588                        y: bounds.y + self.padding.top + 8.0,
589                        width: bounds.width,
590                        height: elem_height,
591                    };
592                    text::Renderer::fill_text(
593                        renderer,
594                        Text {
595                            content: option.as_ref().to_string(),
596                            bounds: bounds.size(),
597                            size: iced::Pixels(text_size),
598                            line_height: self.text_line_height,
599                            font,
600                            align_x: text::Alignment::Left,
601                            align_y: alignment::Vertical::Center,
602                            shaping: text::Shaping::Advanced,
603                            wrapping: text::Wrapping::default(),
604                            ellipsize: text::Ellipsize::default(),
605                        },
606                        bounds.position(),
607                        color,
608                        *viewport,
609                    );
610                }
611
612                OptionElement::Separator => {
613                    let divider = crate::widget::divider::horizontal::light().height(1.0);
614
615                    let layout_node = layout::Node::new(Size {
616                        width: bounds.width,
617                        height: 1.0,
618                    })
619                    .move_to(Point {
620                        x: bounds.x,
621                        y: bounds.y + (self.padding.y() / 2.0) - 4.0,
622                    });
623
624                    Widget::<Message, crate::Theme, crate::Renderer>::draw(
625                        crate::Element::<Message>::from(divider).as_widget(),
626                        &Tree::empty(),
627                        renderer,
628                        theme,
629                        style,
630                        Layout::new(&layout_node),
631                        cursor,
632                        viewport,
633                    );
634                }
635
636                OptionElement::Description(description) => {
637                    let bounds = Rectangle {
638                        x: bounds.center_x(),
639                        y: bounds.center_y(),
640                        ..bounds
641                    };
642                    text::Renderer::fill_text(
643                        renderer,
644                        Text {
645                            content: description.as_ref().to_string(),
646                            bounds: bounds.size(),
647                            size: iced::Pixels(text_size),
648                            line_height: text::LineHeight::Absolute(Pixels(text_line_height + 4.0)),
649                            font: crate::font::default(),
650                            align_x: text::Alignment::Center,
651                            align_y: alignment::Vertical::Center,
652                            shaping: text::Shaping::Advanced,
653                            wrapping: text::Wrapping::default(),
654                            ellipsize: text::Ellipsize::default(),
655                        },
656                        bounds.position(),
657                        appearance.description_color,
658                        *viewport,
659                    );
660                }
661            }
662        }
663    }
664}
665
666impl<'a, S, Item, Message: 'a> From<InnerList<'a, S, Item, Message>>
667    for Element<'a, Message, crate::Theme, crate::Renderer>
668where
669    S: AsRef<str>,
670    Item: Clone + PartialEq,
671{
672    fn from(list: InnerList<'a, S, Item, Message>) -> Self {
673        Element::new(list)
674    }
675}
676
677pub(super) enum OptionElement<'a, S, Item> {
678    Description(&'a S),
679    Option(&'a (S, Item)),
680    Separator,
681}
682
683impl<S, Message> Model<S, Message> {
684    pub(super) fn elements(&self) -> impl Iterator<Item = OptionElement<'_, S, Message>> + '_ {
685        self.lists.iter().flat_map(|list| {
686            let description = list
687                .description
688                .as_ref()
689                .into_iter()
690                .map(OptionElement::Description);
691
692            let options = list.options.iter().map(OptionElement::Option);
693
694            description
695                .chain(options)
696                .chain(std::iter::once(OptionElement::Separator))
697        })
698    }
699
700    fn element_heights(
701        &self,
702        vertical_padding: f32,
703        text_line_height: f32,
704    ) -> impl Iterator<Item = f32> + '_ {
705        self.elements().map(move |element| match element {
706            OptionElement::Option(_) => vertical_padding + text_line_height,
707            OptionElement::Separator => (vertical_padding / 2.0) + 1.0,
708            OptionElement::Description(_) => vertical_padding + text_line_height + 4.0,
709        })
710    }
711
712    fn visible_options(
713        &self,
714        padding_vertical: f32,
715        text_line_height: f32,
716        offset: f32,
717        height: f32,
718    ) -> impl Iterator<Item = (OptionElement<'_, S, Message>, f32)> + '_ {
719        let heights = self.element_heights(padding_vertical, text_line_height);
720
721        let mut current = 0.0;
722        self.elements()
723            .zip(heights)
724            .filter(move |(_, element_height)| {
725                let end = current + element_height;
726                let visible = current >= offset && end <= offset + height;
727                current = end;
728                visible
729            })
730    }
731}