cosmic/widget/dropdown/multi/
widget.rs

1// Copyright 2023 System76 <info@system76.com>
2// Copyright 2019 Héctor Ramón, Iced contributors
3// SPDX-License-Identifier: MPL-2.0 AND MIT
4
5use super::menu::{self, Menu};
6use crate::widget::icon;
7use derive_setters::Setters;
8use iced_core::event::{self, Event};
9use iced_core::text::{self, Paragraph, Text};
10use iced_core::widget::tree::{self, Tree};
11use iced_core::{
12    Clipboard, Layout, Length, Padding, Pixels, Rectangle, Shell, Size, Vector, Widget,
13};
14use iced_core::{Shadow, alignment, keyboard, layout, mouse, overlay, renderer, svg, touch};
15use iced_widget::pick_list;
16use std::ffi::OsStr;
17
18pub use iced_widget::pick_list::{Catalog, Style};
19
20/// A widget for selecting a single value from a list of selections.
21#[derive(Setters)]
22pub struct Dropdown<'a, S: AsRef<str>, Message, Item> {
23    #[setters(skip)]
24    on_selected: Box<dyn Fn(Item) -> Message + 'a>,
25    #[setters(skip)]
26    selections: &'a super::Model<S, Item>,
27    #[setters(into)]
28    width: Length,
29    gap: f32,
30    #[setters(into)]
31    padding: Padding,
32    #[setters(strip_option)]
33    text_size: Option<f32>,
34    text_line_height: text::LineHeight,
35    #[setters(strip_option)]
36    font: Option<crate::font::Font>,
37}
38
39impl<'a, S: AsRef<str>, Message, Item: Clone + PartialEq + 'static> Dropdown<'a, S, Message, Item> {
40    /// The default gap.
41    pub const DEFAULT_GAP: f32 = 4.0;
42
43    /// The default padding.
44    pub const DEFAULT_PADDING: Padding = Padding::new(8.0);
45
46    /// Creates a new [`Dropdown`] with the given list of selections, the current
47    /// selected value, and the message to produce when an option is selected.
48    pub fn new(
49        selections: &'a super::Model<S, Item>,
50        on_selected: impl Fn(Item) -> Message + 'a,
51    ) -> Self {
52        Self {
53            on_selected: Box::new(on_selected),
54            selections,
55            width: Length::Shrink,
56            gap: Self::DEFAULT_GAP,
57            padding: Self::DEFAULT_PADDING,
58            text_size: None,
59            text_line_height: text::LineHeight::Relative(1.2),
60            font: None,
61        }
62    }
63}
64
65impl<'a, S: AsRef<str>, Message: 'a, Item: Clone + PartialEq + 'static>
66    Widget<Message, crate::Theme, crate::Renderer> for Dropdown<'a, S, Message, Item>
67{
68    fn tag(&self) -> tree::Tag {
69        tree::Tag::of::<State<Item>>()
70    }
71
72    fn state(&self) -> tree::State {
73        tree::State::new(State::<Item>::new())
74    }
75
76    fn size(&self) -> Size<Length> {
77        Size::new(self.width, Length::Shrink)
78    }
79
80    fn layout(
81        &self,
82        tree: &mut Tree,
83        renderer: &crate::Renderer,
84        limits: &layout::Limits,
85    ) -> layout::Node {
86        layout(
87            renderer,
88            limits,
89            self.width,
90            self.gap,
91            self.padding,
92            self.text_size.unwrap_or(14.0),
93            self.text_line_height,
94            self.font,
95            self.selections.selected.as_ref().and_then(|id| {
96                self.selections.get(id).map(AsRef::as_ref).zip({
97                    let state = tree.state.downcast_mut::<State<Item>>();
98
99                    if state.selections.is_empty() {
100                        for list in &self.selections.lists {
101                            for (_, item) in &list.options {
102                                state
103                                    .selections
104                                    .push((item.clone(), crate::Plain::default()));
105                            }
106                        }
107                    }
108
109                    state
110                        .selections
111                        .iter_mut()
112                        .find(|(i, _)| i == id)
113                        .map(|(_, p)| p)
114                })
115            }),
116        )
117    }
118
119    fn on_event(
120        &mut self,
121        tree: &mut Tree,
122        event: Event,
123        layout: Layout<'_>,
124        cursor: mouse::Cursor,
125        _renderer: &crate::Renderer,
126        _clipboard: &mut dyn Clipboard,
127        shell: &mut Shell<'_, Message>,
128        _viewport: &Rectangle,
129    ) -> event::Status {
130        update(
131            &event,
132            layout,
133            cursor,
134            shell,
135            self.on_selected.as_ref(),
136            self.selections,
137            || tree.state.downcast_mut::<State<Item>>(),
138        )
139    }
140
141    fn mouse_interaction(
142        &self,
143        _tree: &Tree,
144        layout: Layout<'_>,
145        cursor: mouse::Cursor,
146        _viewport: &Rectangle,
147        _renderer: &crate::Renderer,
148    ) -> mouse::Interaction {
149        mouse_interaction(layout, cursor)
150    }
151
152    fn draw(
153        &self,
154        tree: &Tree,
155        renderer: &mut crate::Renderer,
156        theme: &crate::Theme,
157        _style: &iced_core::renderer::Style,
158        layout: Layout<'_>,
159        cursor: mouse::Cursor,
160        viewport: &Rectangle,
161    ) {
162        let font = self.font.unwrap_or_else(crate::font::default);
163
164        draw(
165            renderer,
166            theme,
167            layout,
168            cursor,
169            self.gap,
170            self.padding,
171            self.text_size,
172            self.text_line_height,
173            font,
174            self.selections
175                .selected
176                .as_ref()
177                .and_then(|id| self.selections.get(id)),
178            tree.state.downcast_ref::<State<Item>>(),
179            viewport,
180        );
181    }
182
183    fn overlay<'b>(
184        &'b mut self,
185        tree: &'b mut Tree,
186        layout: Layout<'_>,
187        renderer: &crate::Renderer,
188        translation: Vector,
189    ) -> Option<overlay::Element<'b, Message, crate::Theme, crate::Renderer>> {
190        let state = tree.state.downcast_mut::<State<Item>>();
191
192        overlay(
193            layout,
194            renderer,
195            state,
196            self.gap,
197            self.padding,
198            self.text_size.unwrap_or(14.0),
199            self.font,
200            self.text_line_height,
201            self.selections,
202            &self.on_selected,
203            translation,
204        )
205    }
206}
207
208impl<'a, S: AsRef<str>, Message: 'a, Item: Clone + PartialEq + 'static>
209    From<Dropdown<'a, S, Message, Item>> for crate::Element<'a, Message>
210{
211    fn from(pick_list: Dropdown<'a, S, Message, Item>) -> Self {
212        Self::new(pick_list)
213    }
214}
215
216/// The local state of a [`Dropdown`].
217#[derive(Debug)]
218pub struct State<Item: Clone + PartialEq + 'static> {
219    icon: Option<svg::Handle>,
220    menu: menu::State,
221    keyboard_modifiers: keyboard::Modifiers,
222    is_open: bool,
223    hovered_option: Option<Item>,
224    selections: Vec<(Item, crate::Plain)>,
225    descriptions: Vec<crate::Plain>,
226}
227
228impl<Item: Clone + PartialEq + 'static> State<Item> {
229    /// Creates a new [`State`] for a [`Dropdown`].
230    pub fn new() -> Self {
231        Self {
232            icon: match icon::from_name("pan-down-symbolic").size(16).handle().data {
233                icon::Data::Name(named) => named
234                    .path()
235                    .filter(|path| path.extension().is_some_and(|ext| ext == OsStr::new("svg")))
236                    .map(iced_core::svg::Handle::from_path),
237                icon::Data::Svg(handle) => Some(handle),
238                icon::Data::Image(_) => None,
239            },
240            menu: menu::State::default(),
241            keyboard_modifiers: keyboard::Modifiers::default(),
242            is_open: false,
243            hovered_option: None,
244            selections: Vec::new(),
245            descriptions: Vec::new(),
246        }
247    }
248}
249
250impl<Item: Clone + PartialEq + 'static> Default for State<Item> {
251    fn default() -> Self {
252        Self::new()
253    }
254}
255
256/// Computes the layout of a [`Dropdown`].
257#[allow(clippy::too_many_arguments)]
258pub fn layout(
259    renderer: &crate::Renderer,
260    limits: &layout::Limits,
261    width: Length,
262    gap: f32,
263    padding: Padding,
264    text_size: f32,
265    text_line_height: text::LineHeight,
266    font: Option<crate::font::Font>,
267    selection: Option<(&str, &mut crate::Plain)>,
268) -> layout::Node {
269    use std::f32;
270
271    let limits = limits.width(width).height(Length::Shrink).shrink(padding);
272
273    let max_width = match width {
274        Length::Shrink => {
275            let measure = move |(label, paragraph): (_, &mut crate::Plain)| -> f32 {
276                paragraph.update(Text {
277                    content: label,
278                    bounds: Size::new(f32::MAX, f32::MAX),
279                    size: iced::Pixels(text_size),
280                    line_height: text_line_height,
281                    font: font.unwrap_or_else(crate::font::default),
282                    horizontal_alignment: alignment::Horizontal::Left,
283                    vertical_alignment: alignment::Vertical::Top,
284                    shaping: text::Shaping::Advanced,
285                    wrapping: text::Wrapping::default(),
286                });
287                paragraph.min_width().round()
288            };
289
290            selection.map(measure).unwrap_or_default()
291        }
292        _ => 0.0,
293    };
294
295    let size = {
296        let intrinsic = Size::new(
297            max_width + gap + 16.0,
298            f32::from(text_line_height.to_absolute(Pixels(text_size))),
299        );
300
301        limits
302            .resolve(width, Length::Shrink, intrinsic)
303            .expand(padding)
304    };
305
306    layout::Node::new(size)
307}
308
309/// Processes an [`Event`] and updates the [`State`] of a [`Dropdown`]
310/// accordingly.
311#[allow(clippy::too_many_arguments)]
312pub fn update<'a, S: AsRef<str>, Message, Item: Clone + PartialEq + 'static + 'a>(
313    event: &Event,
314    layout: Layout<'_>,
315    cursor: mouse::Cursor,
316    shell: &mut Shell<'_, Message>,
317    on_selected: &dyn Fn(Item) -> Message,
318    selections: &super::Model<S, Item>,
319    state: impl FnOnce() -> &'a mut State<Item>,
320) -> event::Status {
321    match event {
322        Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
323        | Event::Touch(touch::Event::FingerPressed { .. }) => {
324            let state = state();
325
326            if state.is_open {
327                // Event wasn't processed by overlay, so cursor was clicked either outside it's
328                // bounds or on the drop-down, either way we close the overlay.
329                state.is_open = false;
330
331                event::Status::Captured
332            } else if cursor.is_over(layout.bounds()) {
333                state.is_open = true;
334                state.hovered_option = selections.selected.clone();
335
336                event::Status::Captured
337            } else {
338                event::Status::Ignored
339            }
340        }
341        Event::Mouse(mouse::Event::WheelScrolled {
342            delta: mouse::ScrollDelta::Lines { .. },
343        }) => {
344            let state = state();
345
346            if state.keyboard_modifiers.command()
347                && cursor.is_over(layout.bounds())
348                && !state.is_open
349            {
350                if let Some(option) = selections.next() {
351                    shell.publish((on_selected)(option.1.clone()));
352                }
353
354                event::Status::Captured
355            } else {
356                event::Status::Ignored
357            }
358        }
359        Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
360            let state = state();
361
362            state.keyboard_modifiers = *modifiers;
363
364            event::Status::Ignored
365        }
366        _ => event::Status::Ignored,
367    }
368}
369
370/// Returns the current [`mouse::Interaction`] of a [`Dropdown`].
371#[must_use]
372pub fn mouse_interaction(layout: Layout<'_>, cursor: mouse::Cursor) -> mouse::Interaction {
373    let bounds = layout.bounds();
374    let is_mouse_over = cursor.is_over(bounds);
375
376    if is_mouse_over {
377        mouse::Interaction::Pointer
378    } else {
379        mouse::Interaction::default()
380    }
381}
382
383/// Returns the current overlay of a [`Dropdown`].
384#[allow(clippy::too_many_arguments)]
385pub fn overlay<'a, S: AsRef<str>, Message: 'a, Item: Clone + PartialEq + 'static>(
386    layout: Layout<'_>,
387    renderer: &crate::Renderer,
388    state: &'a mut State<Item>,
389    gap: f32,
390    padding: Padding,
391    text_size: f32,
392    font: Option<crate::font::Font>,
393    text_line_height: text::LineHeight,
394    selections: &'a super::Model<S, Item>,
395    on_selected: &'a dyn Fn(Item) -> Message,
396    translation: Vector,
397) -> Option<overlay::Element<'a, Message, crate::Theme, crate::Renderer>> {
398    if state.is_open {
399        let description_line_height = text::LineHeight::Absolute(Pixels(
400            text_line_height.to_absolute(Pixels(text_size)).0 + 4.0,
401        ));
402
403        let bounds = layout.bounds();
404
405        let menu = Menu::new(
406            &mut state.menu,
407            selections,
408            &mut state.hovered_option,
409            selections.selected.as_ref(),
410            |option| {
411                state.is_open = false;
412
413                (on_selected)(option)
414            },
415            None,
416        )
417        .width({
418            let measure =
419                |label: &str, paragraph: &mut crate::Plain, line_height: text::LineHeight| {
420                    paragraph.update(Text {
421                        content: label,
422                        bounds: Size::new(f32::MAX, f32::MAX),
423                        size: iced::Pixels(text_size),
424                        line_height,
425                        font: font.unwrap_or_else(crate::font::default),
426                        horizontal_alignment: alignment::Horizontal::Left,
427                        vertical_alignment: alignment::Vertical::Top,
428                        shaping: text::Shaping::Advanced,
429                        wrapping: text::Wrapping::default(),
430                    });
431                    paragraph.min_width().round()
432                };
433
434            let mut desc_count = 0;
435            selections
436                .elements()
437                .map(|element| match element {
438                    super::menu::OptionElement::Description(desc) => {
439                        let paragraph = if state.descriptions.len() > desc_count {
440                            &mut state.descriptions[desc_count]
441                        } else {
442                            state.descriptions.push(crate::Plain::default());
443                            state.descriptions.last_mut().unwrap()
444                        };
445                        desc_count += 1;
446                        measure(desc.as_ref(), paragraph, description_line_height)
447                    }
448
449                    super::menu::OptionElement::Option((option, item)) => {
450                        let selection_index = state.selections.iter().position(|(i, _)| i == item);
451
452                        let selection_index = match selection_index {
453                            Some(index) => index,
454                            None => {
455                                state
456                                    .selections
457                                    .push((item.clone(), crate::Plain::default()));
458                                state.selections.len() - 1
459                            }
460                        };
461
462                        let paragraph = &mut state.selections[selection_index].1;
463
464                        measure(option.as_ref(), paragraph, text_line_height)
465                    }
466
467                    super::menu::OptionElement::Separator => 1.0,
468                })
469                .fold(0.0, |next, current| current.max(next))
470                + gap
471                + 16.0
472                + (padding.horizontal() * 2.0)
473        })
474        .padding(padding)
475        .text_size(text_size);
476
477        let mut position = layout.position();
478        position.x -= padding.left;
479        position.x += translation.x;
480        position.y += translation.y;
481        Some(menu.overlay(position, bounds.height))
482    } else {
483        None
484    }
485}
486
487/// Draws a [`Dropdown`].
488#[allow(clippy::too_many_arguments)]
489pub fn draw<'a, S, Item: Clone + PartialEq + 'static>(
490    renderer: &mut crate::Renderer,
491    theme: &crate::Theme,
492    layout: Layout<'_>,
493    cursor: mouse::Cursor,
494    gap: f32,
495    padding: Padding,
496    text_size: Option<f32>,
497    text_line_height: text::LineHeight,
498    font: crate::font::Font,
499    selected: Option<&'a S>,
500    state: &'a State<Item>,
501    viewport: &Rectangle,
502) where
503    S: AsRef<str> + 'a,
504{
505    let bounds = layout.bounds();
506    let is_mouse_over = cursor.is_over(bounds);
507
508    let style = if is_mouse_over {
509        theme.style(&(), pick_list::Status::Hovered)
510    } else {
511        theme.style(&(), pick_list::Status::Active)
512    };
513
514    iced_core::Renderer::fill_quad(
515        renderer,
516        renderer::Quad {
517            bounds,
518            border: style.border,
519            shadow: Shadow::default(),
520        },
521        style.background,
522    );
523
524    if let Some(handle) = state.icon.clone() {
525        let svg_handle = iced_core::Svg::new(handle).color(style.text_color);
526        svg::Renderer::draw_svg(
527            renderer,
528            svg_handle,
529            Rectangle {
530                x: bounds.x + bounds.width - gap - 16.0,
531                y: bounds.center_y() - 8.0,
532                width: 16.0,
533                height: 16.0,
534            },
535        );
536    }
537
538    if let Some(content) = selected.map(AsRef::as_ref) {
539        let text_size = text_size.unwrap_or_else(|| text::Renderer::default_size(renderer).0);
540
541        let bounds = Rectangle {
542            x: bounds.x + padding.left,
543            y: bounds.center_y(),
544            width: bounds.width - padding.horizontal(),
545            height: f32::from(text_line_height.to_absolute(Pixels(text_size))),
546        };
547
548        text::Renderer::fill_text(
549            renderer,
550            Text {
551                content: content.to_string(),
552                size: iced::Pixels(text_size),
553                line_height: text_line_height,
554                font,
555                bounds: bounds.size(),
556                horizontal_alignment: alignment::Horizontal::Left,
557                vertical_alignment: alignment::Vertical::Center,
558                shaping: text::Shaping::Advanced,
559                wrapping: text::Wrapping::default(),
560            },
561            bounds.position(),
562            style.text_color,
563            *viewport,
564        );
565    }
566}