Skip to main content

material_ui_rs/widget/component/
select.rs

1//! Material select widget.
2//!
3//! iced's built-in pick list opens the menu on whichever side of the field has
4//! more vertical space. Material selects should prefer opening below the field
5//! when the menu fits there, so this widget keeps the iced pick list behavior
6//! while adjusting the overlay anchor before handing off to iced's menu overlay.
7
8use std::borrow::Borrow;
9use std::f32::consts::PI;
10use std::fmt;
11
12use iced_widget::canvas::{Frame, Path};
13use iced_widget::core::text::paragraph;
14use iced_widget::core::text::{self, Text};
15use iced_widget::core::time::Instant;
16use iced_widget::core::widget::tree::{self, Tree};
17use iced_widget::core::{
18    Clipboard, Color, Element, Event, Layout, Length, Padding, Pixels, Point, Rectangle, Shell,
19    Size, Vector, Widget, alignment, keyboard, layout, mouse, overlay, renderer, touch, window,
20};
21use iced_widget::graphics::geometry;
22use iced_widget::overlay::menu;
23use iced_widget::pick_list::{self as iced_select, Handle, Icon, Status};
24
25use super::support::{AnimatedScalar, duration_ms};
26use super::{
27    absolute_line_height, draw_text_field_outline, menu_overlay, text_field_floating_label_notch,
28};
29use crate::style::{menu as menu_style, select as select_style};
30use crate::{Theme, tokens};
31
32const MAX_VISIBLE_OPTIONS: usize = 5;
33const DIRECTION_EPSILON: f32 = 0.5;
34const MENU_HANDLE_ROTATION_DURATION_MS: u16 = tokens::motion::DURATION_SHORT3_MS;
35const MENU_HANDLE_VIEWPORT_SIZE: f32 = 24.0;
36const MENU_HANDLE_ARROW_LEFT_X: f32 = 7.0;
37const MENU_HANDLE_ARROW_CENTER_X: f32 = 12.0;
38const MENU_HANDLE_ARROW_RIGHT_X: f32 = 17.0;
39const MENU_HANDLE_ARROW_TOP_Y: f32 = 10.0;
40const MENU_HANDLE_ARROW_BOTTOM_Y: f32 = 15.0;
41
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub(crate) struct MenuAnchor {
44    pub(crate) position: Point,
45    pub(crate) target_height: f32,
46}
47
48/// Creates a Material outlined select field.
49pub fn outlined<'a, T, L, V, Message, Renderer>(
50    options: L,
51    selected: Option<V>,
52    on_select: impl Fn(T) -> Message + 'a,
53) -> Select<'a, T, L, V, Message, Renderer>
54where
55    T: ToString + PartialEq + Clone + 'a,
56    L: Borrow<[T]> + 'a,
57    V: Borrow<T> + 'a,
58    Message: Clone + 'a,
59    Renderer: text::Renderer + 'a,
60{
61    Select::new(options, selected, on_select)
62        .padding(Padding {
63            top: tokens::component::text_field::TOP_SPACE,
64            right: tokens::component::text_field::TRAILING_SPACE,
65            bottom: tokens::component::text_field::BOTTOM_SPACE,
66            left: tokens::component::text_field::LEADING_SPACE,
67        })
68        .option_padding(menu_option_padding())
69        .text_size(tokens::component::text_field::INPUT_TEXT_SIZE)
70        .text_line_height(absolute_line_height(
71            tokens::component::text_field::INPUT_TEXT_LINE_HEIGHT,
72        ))
73        .width(Length::Fill)
74        .style(select_style::default)
75        .menu_style(menu_style::outlined_select)
76}
77
78/// A Material select field.
79pub struct Select<'a, T, L, V, Message, Renderer>
80where
81    T: ToString + PartialEq + Clone,
82    L: Borrow<[T]> + 'a,
83    V: Borrow<T> + 'a,
84    Renderer: text::Renderer,
85{
86    on_select: Box<dyn Fn(T) -> Message + 'a>,
87    on_open: Option<Message>,
88    on_close: Option<Message>,
89    options: L,
90    label: Option<String>,
91    placeholder: Option<String>,
92    selected: Option<V>,
93    width: Length,
94    field_padding: Padding,
95    option_padding: Padding,
96    text_size: Option<Pixels>,
97    text_line_height: text::LineHeight,
98    text_shaping: text::Shaping,
99    font: Option<Renderer::Font>,
100    handle: Handle<Renderer::Font>,
101    class: <Theme as iced_select::Catalog>::Class<'a>,
102    menu_class: <Theme as menu::Catalog>::Class<'a>,
103    menu_height: Length,
104}
105
106impl<T, L, V, Message, Renderer> fmt::Debug for Select<'_, T, L, V, Message, Renderer>
107where
108    T: ToString + PartialEq + Clone,
109    L: Borrow<[T]>,
110    V: Borrow<T>,
111    Renderer: text::Renderer,
112{
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.debug_struct("Select").finish_non_exhaustive()
115    }
116}
117
118impl<'a, T, L, V, Message, Renderer> Select<'a, T, L, V, Message, Renderer>
119where
120    T: ToString + PartialEq + Clone,
121    L: Borrow<[T]> + 'a,
122    V: Borrow<T> + 'a,
123    Message: Clone,
124    Renderer: text::Renderer,
125{
126    /// Creates a new [`Select`] with the given list of options, selected value,
127    /// and message to produce when an option is selected.
128    pub fn new(options: L, selected: Option<V>, on_select: impl Fn(T) -> Message + 'a) -> Self {
129        let option_count = options.borrow().len();
130
131        Self {
132            on_select: Box::new(on_select),
133            on_open: None,
134            on_close: None,
135            options,
136            label: None,
137            placeholder: None,
138            selected,
139            width: Length::Shrink,
140            field_padding: iced_widget::button::DEFAULT_PADDING,
141            option_padding: menu_option_padding(),
142            text_size: None,
143            text_line_height: text::LineHeight::default(),
144            text_shaping: text::Shaping::default(),
145            font: None,
146            handle: Handle::default(),
147            class: <Theme as iced_select::Catalog>::default(),
148            menu_class: <Theme as iced_select::Catalog>::default_menu(),
149            menu_height: material_menu_height(option_count),
150        }
151    }
152
153    /// Sets the placeholder of the select.
154    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
155        self.placeholder = Some(placeholder.into());
156        self
157    }
158
159    /// Sets the floating label of the select.
160    pub fn label(mut self, label: impl Into<String>) -> Self {
161        self.label = Some(label.into());
162        self
163    }
164
165    /// Sets the width of the select.
166    pub fn width(mut self, width: impl Into<Length>) -> Self {
167        self.width = width.into();
168        self
169    }
170
171    /// Sets the height of the menu.
172    pub fn menu_height(mut self, menu_height: impl Into<Length>) -> Self {
173        self.menu_height = menu_height.into();
174        self
175    }
176
177    /// Sets the padding of the select field.
178    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
179        self.field_padding = padding.into();
180        self
181    }
182
183    /// Sets the padding of each menu option.
184    pub fn option_padding(mut self, padding: impl Into<Padding>) -> Self {
185        self.option_padding = padding.into();
186        self
187    }
188
189    /// Sets the text size of the select and its menu items.
190    pub fn text_size(mut self, size: impl Into<Pixels>) -> Self {
191        self.text_size = Some(size.into());
192        self
193    }
194
195    /// Sets the text line height of the select and its menu items.
196    pub fn text_line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
197        self.text_line_height = line_height.into();
198        self
199    }
200
201    /// Sets the text shaping strategy.
202    pub fn text_shaping(mut self, shaping: text::Shaping) -> Self {
203        self.text_shaping = shaping;
204        self
205    }
206
207    /// Sets the font.
208    pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
209        self.font = Some(font.into());
210        self
211    }
212
213    /// Sets the trailing handle.
214    pub fn handle(mut self, handle: Handle<Renderer::Font>) -> Self {
215        self.handle = handle;
216        self
217    }
218
219    /// Sets the message produced when the menu is opened.
220    pub fn on_open(mut self, on_open: Message) -> Self {
221        self.on_open = Some(on_open);
222        self
223    }
224
225    /// Sets the message produced when the menu is closed.
226    pub fn on_close(mut self, on_close: Message) -> Self {
227        self.on_close = Some(on_close);
228        self
229    }
230
231    /// Sets the style of the select.
232    pub fn style(mut self, style: impl Fn(&Theme, Status) -> iced_select::Style + 'a) -> Self
233    where
234        <Theme as iced_select::Catalog>::Class<'a>: From<iced_select::StyleFn<'a, Theme>>,
235    {
236        self.class = Box::new(style) as iced_select::StyleFn<'a, Theme>;
237        self
238    }
239
240    /// Sets the style of the menu.
241    pub fn menu_style(mut self, style: impl Fn(&Theme) -> menu::Style + 'a) -> Self
242    where
243        <Theme as menu::Catalog>::Class<'a>: From<menu::StyleFn<'a, Theme>>,
244    {
245        self.menu_class = Box::new(style) as menu::StyleFn<'a, Theme>;
246        self
247    }
248
249    fn intrinsic_menu_height(&self, renderer: &Renderer) -> f32 {
250        let text_size = self.text_size.unwrap_or_else(|| renderer.default_size());
251        let option_height =
252            f32::from(self.text_line_height.to_absolute(text_size)) + self.option_padding.y();
253
254        option_height * self.options.borrow().len() as f32
255    }
256}
257
258impl<'a, T, L, V, Message, Renderer> Widget<Message, Theme, Renderer>
259    for Select<'a, T, L, V, Message, Renderer>
260where
261    T: Clone + ToString + PartialEq + 'a,
262    L: Borrow<[T]>,
263    V: Borrow<T>,
264    Message: Clone + 'a,
265    Renderer: text::Renderer + geometry::Renderer + 'a,
266{
267    fn tag(&self) -> tree::Tag {
268        tree::Tag::of::<State<Renderer::Paragraph>>()
269    }
270
271    fn state(&self) -> tree::State {
272        tree::State::new(State::<Renderer::Paragraph>::new())
273    }
274
275    fn size(&self) -> Size<Length> {
276        Size {
277            width: self.width,
278            height: Length::Shrink,
279        }
280    }
281
282    fn layout(
283        &mut self,
284        tree: &mut Tree,
285        renderer: &Renderer,
286        limits: &layout::Limits,
287    ) -> layout::Node {
288        let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
289
290        let font = self.font.unwrap_or_else(|| renderer.default_font());
291        let text_size = self.text_size.unwrap_or_else(|| renderer.default_size());
292        let options = self.options.borrow();
293
294        state.options.resize_with(options.len(), Default::default);
295
296        let option_text = Text {
297            content: "",
298            bounds: Size::new(
299                f32::INFINITY,
300                self.text_line_height.to_absolute(text_size).into(),
301            ),
302            size: text_size,
303            line_height: self.text_line_height,
304            font,
305            align_x: text::Alignment::Default,
306            align_y: alignment::Vertical::Center,
307            shaping: self.text_shaping,
308            wrapping: text::Wrapping::default(),
309        };
310
311        for (option, paragraph) in options.iter().zip(state.options.iter_mut()) {
312            let label = option.to_string();
313
314            let _ = paragraph.update(Text {
315                content: &label,
316                ..option_text
317            });
318        }
319
320        if let Some(placeholder) = &self.placeholder {
321            let _ = state.placeholder.update(Text {
322                content: placeholder,
323                ..option_text
324            });
325        }
326
327        if let Some(label) = &self.label {
328            let _ = state.label.update(Text {
329                content: label,
330                size: Pixels(tokens::component::text_field::LABEL_TEXT_POPULATED_SIZE),
331                line_height: text::LineHeight::Absolute(Pixels(
332                    tokens::component::text_field::LABEL_TEXT_POPULATED_LINE_HEIGHT,
333                )),
334                ..option_text
335            });
336        }
337
338        let max_width = match self.width {
339            Length::Shrink => {
340                let labels_width = state.options.iter().fold(0.0, |width, paragraph| {
341                    f32::max(width, paragraph.min_width())
342                });
343
344                labels_width
345                    .max(
346                        self.placeholder
347                            .as_ref()
348                            .map(|_| state.placeholder.min_width())
349                            .unwrap_or(0.0),
350                    )
351                    .max(
352                        self.label
353                            .as_ref()
354                            .map(|_| state.label.min_width())
355                            .unwrap_or(0.0),
356                    )
357            }
358            _ => 0.0,
359        };
360
361        let size = {
362            let intrinsic = Size::new(
363                max_width + text_size.0 + self.field_padding.left,
364                f32::from(self.text_line_height.to_absolute(text_size)),
365            );
366
367            limits
368                .width(self.width)
369                .shrink(self.field_padding)
370                .resolve(self.width, Length::Shrink, intrinsic)
371                .expand(self.field_padding)
372        };
373
374        layout::Node::new(size)
375    }
376
377    fn update(
378        &mut self,
379        tree: &mut Tree,
380        event: &Event,
381        layout: Layout<'_>,
382        cursor: mouse::Cursor,
383        _renderer: &Renderer,
384        _clipboard: &mut dyn Clipboard,
385        shell: &mut Shell<'_, Message>,
386        _viewport: &Rectangle,
387    ) {
388        let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
389
390        match event {
391            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
392            | Event::Touch(touch::Event::FingerPressed { .. }) => {
393                if state.is_open {
394                    let now = Instant::now();
395
396                    state.set_open(false, now);
397
398                    if let Some(on_close) = &self.on_close {
399                        shell.publish(on_close.clone());
400                    }
401
402                    shell.capture_event();
403                } else if cursor.is_over(layout.bounds()) {
404                    let selected = self.selected.as_ref().map(Borrow::borrow);
405                    let now = Instant::now();
406
407                    state.set_open(true, now);
408                    state.hovered_option = self
409                        .options
410                        .borrow()
411                        .iter()
412                        .position(|option| Some(option) == selected);
413
414                    if let Some(on_open) = &self.on_open {
415                        shell.publish(on_open.clone());
416                    }
417
418                    shell.capture_event();
419                }
420            }
421            Event::Mouse(mouse::Event::WheelScrolled {
422                delta: mouse::ScrollDelta::Lines { y, .. },
423            }) if state.keyboard_modifiers.command()
424                && cursor.is_over(layout.bounds())
425                && !state.is_open =>
426            {
427                let options = self.options.borrow();
428                let selected = self.selected.as_ref().map(Borrow::borrow);
429
430                let next_option = if *y < 0.0 {
431                    if let Some(selected) = selected {
432                        find_next(selected, options.iter())
433                    } else {
434                        options.first()
435                    }
436                } else if *y > 0.0 {
437                    if let Some(selected) = selected {
438                        find_next(selected, options.iter().rev())
439                    } else {
440                        options.last()
441                    }
442                } else {
443                    None
444                };
445
446                if let Some(next_option) = next_option {
447                    shell.publish((self.on_select)(next_option.clone()));
448                }
449
450                shell.capture_event();
451            }
452            Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
453                state.keyboard_modifiers = *modifiers;
454            }
455            _ => {}
456        };
457
458        let now = match event {
459            Event::Window(window::Event::RedrawRequested(now)) => Some(*now),
460            _ => None,
461        };
462
463        if let Some(now) = now
464            && state.advance(now)
465        {
466            shell.request_redraw();
467        }
468
469        let status = select_status(state.is_open, cursor.is_over(layout.bounds()));
470
471        if state.last_status != Some(status) {
472            state.last_status = Some(status);
473            shell.request_redraw();
474        } else if state.is_animating() {
475            shell.request_redraw();
476        }
477    }
478
479    fn mouse_interaction(
480        &self,
481        _tree: &Tree,
482        layout: Layout<'_>,
483        cursor: mouse::Cursor,
484        _viewport: &Rectangle,
485        _renderer: &Renderer,
486    ) -> mouse::Interaction {
487        if cursor.is_over(layout.bounds()) {
488            mouse::Interaction::Pointer
489        } else {
490            mouse::Interaction::default()
491        }
492    }
493
494    fn draw(
495        &self,
496        tree: &Tree,
497        renderer: &mut Renderer,
498        theme: &Theme,
499        _style: &renderer::Style,
500        layout: Layout<'_>,
501        cursor: mouse::Cursor,
502        viewport: &Rectangle,
503    ) {
504        let font = self.font.unwrap_or_else(|| renderer.default_font());
505        let selected = self.selected.as_ref().map(Borrow::borrow);
506        let state = tree.state.downcast_ref::<State<Renderer::Paragraph>>();
507
508        let bounds = layout.bounds();
509        let status = state
510            .last_status
511            .unwrap_or_else(|| select_status(state.is_open, cursor.is_over(bounds)));
512
513        let style = <Theme as iced_select::Catalog>::style(theme, &self.class, status);
514
515        let label_width = self
516            .label
517            .as_ref()
518            .map(|_| state.label.min_width())
519            .unwrap_or(0.0);
520        let label_x = bounds.x + tokens::component::text_field::LEADING_SPACE;
521        let label_notch = self.label.as_ref().and_then(|_| {
522            text_field_floating_label_notch(bounds, label_x, label_width, label_width, 1.0)
523        });
524
525        draw_text_field_outline(
526            renderer,
527            bounds,
528            style.background,
529            style.border,
530            label_notch,
531        );
532
533        let text_handle = match &self.handle {
534            Handle::Arrow { size } => {
535                let size = size.unwrap_or(Pixels(tokens::component::select::TRAILING_ICON_SIZE));
536                let right = bounds.x + bounds.width - self.field_padding.right;
537                let center = Point::new(right - size.0 / 2.0, bounds.center_y());
538
539                draw_default_handle(
540                    renderer,
541                    center,
542                    size.0,
543                    state.handle_rotation.value,
544                    style.handle_color,
545                );
546
547                None
548            }
549            Handle::Static(Icon {
550                font,
551                code_point,
552                size,
553                line_height,
554                shaping,
555            }) => Some((*font, *code_point, *size, *line_height, *shaping)),
556            Handle::Dynamic { open, closed } => {
557                if state.is_open {
558                    Some((
559                        open.font,
560                        open.code_point,
561                        open.size,
562                        open.line_height,
563                        open.shaping,
564                    ))
565                } else {
566                    Some((
567                        closed.font,
568                        closed.code_point,
569                        closed.size,
570                        closed.line_height,
571                        closed.shaping,
572                    ))
573                }
574            }
575            Handle::None => None,
576        };
577
578        if let Some((font, code_point, size, line_height, shaping)) = text_handle {
579            let size = size.unwrap_or_else(|| renderer.default_size());
580
581            renderer.fill_text(
582                Text {
583                    content: code_point.to_string(),
584                    size,
585                    line_height,
586                    font,
587                    bounds: Size::new(bounds.width, f32::from(line_height.to_absolute(size))),
588                    align_x: text::Alignment::Right,
589                    align_y: alignment::Vertical::Center,
590                    shaping,
591                    wrapping: text::Wrapping::default(),
592                },
593                Point::new(
594                    bounds.x + bounds.width - self.field_padding.right,
595                    bounds.center_y(),
596                ),
597                style.handle_color,
598                *viewport,
599            );
600        }
601
602        let label = selected.map(ToString::to_string);
603
604        if let Some(label) = label.or_else(|| self.placeholder.clone()) {
605            let text_size = self.text_size.unwrap_or_else(|| renderer.default_size());
606
607            renderer.fill_text(
608                Text {
609                    content: label,
610                    size: text_size,
611                    line_height: self.text_line_height,
612                    font,
613                    bounds: Size::new(
614                        bounds.width - self.field_padding.x(),
615                        f32::from(self.text_line_height.to_absolute(text_size)),
616                    ),
617                    align_x: text::Alignment::Default,
618                    align_y: alignment::Vertical::Center,
619                    shaping: self.text_shaping,
620                    wrapping: text::Wrapping::default(),
621                },
622                Point::new(bounds.x + self.field_padding.left, bounds.center_y()),
623                if selected.is_some() {
624                    style.text_color
625                } else {
626                    style.placeholder_color
627                },
628                *viewport,
629            );
630        }
631
632        if let Some(label) = &self.label {
633            let label_size = Pixels(tokens::component::text_field::LABEL_TEXT_POPULATED_SIZE);
634            let label_line_height = text::LineHeight::Absolute(Pixels(
635                tokens::component::text_field::LABEL_TEXT_POPULATED_LINE_HEIGHT,
636            ));
637            let label_height = f32::from(label_line_height.to_absolute(label_size));
638            let label_y = bounds.y;
639
640            renderer.fill_text(
641                Text {
642                    content: label.clone(),
643                    size: label_size,
644                    line_height: label_line_height,
645                    font,
646                    bounds: Size::new(label_width, label_height),
647                    align_x: text::Alignment::Default,
648                    align_y: alignment::Vertical::Center,
649                    shaping: self.text_shaping,
650                    wrapping: text::Wrapping::None,
651                },
652                Point::new(label_x, label_y),
653                select_label_color(theme, status),
654                *viewport,
655            );
656        }
657    }
658
659    fn overlay<'b>(
660        &'b mut self,
661        tree: &'b mut Tree,
662        layout: Layout<'_>,
663        renderer: &Renderer,
664        viewport: &Rectangle,
665        translation: Vector,
666    ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
667        let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
668        let font = self.font.unwrap_or_else(|| renderer.default_font());
669
670        if state.menu.is_visible() {
671            let bounds = layout.bounds();
672            let on_select = &self.on_select;
673            let menu_state = &mut state.menu;
674            let hovered_option = &mut state.hovered_option;
675            let open_state = &mut state.is_open;
676            let handle_rotation = &mut state.handle_rotation;
677            let last_status = &mut state.last_status;
678
679            let mut menu = menu_overlay::Menu::new(
680                menu_state,
681                self.options.borrow(),
682                hovered_option,
683                on_select,
684                None,
685                &self.menu_class,
686            )
687            .on_dismiss(move |now| {
688                set_menu_open(open_state, handle_rotation, last_status, false, now);
689            })
690            .width(bounds.width)
691            .padding(self.option_padding)
692            .font(font)
693            .text_shaping(self.text_shaping);
694
695            if let Some(text_size) = self.text_size {
696                menu = menu.text_size(text_size);
697            }
698
699            let anchor = prefer_down_when_menu_fits(
700                layout.position() + translation,
701                *viewport,
702                bounds.height,
703                resolved_menu_height(
704                    self.menu_height,
705                    self.intrinsic_menu_height(renderer),
706                    viewport.height,
707                ),
708            );
709
710            Some(menu.overlay(
711                anchor.position,
712                *viewport,
713                anchor.target_height,
714                self.menu_height,
715            ))
716        } else {
717            None
718        }
719    }
720}
721
722impl<'a, T, L, V, Message, Renderer> From<Select<'a, T, L, V, Message, Renderer>>
723    for Element<'a, Message, Theme, Renderer>
724where
725    T: Clone + ToString + PartialEq + 'a,
726    L: Borrow<[T]> + 'a,
727    V: Borrow<T> + 'a,
728    Message: Clone + 'a,
729    Renderer: text::Renderer + geometry::Renderer + 'a,
730{
731    fn from(select: Select<'a, T, L, V, Message, Renderer>) -> Self {
732        Self::new(select)
733    }
734}
735
736#[derive(Debug)]
737struct State<P: text::Paragraph> {
738    menu: menu_overlay::State,
739    keyboard_modifiers: keyboard::Modifiers,
740    is_open: bool,
741    hovered_option: Option<usize>,
742    options: Vec<paragraph::Plain<P>>,
743    placeholder: paragraph::Plain<P>,
744    label: paragraph::Plain<P>,
745    handle_rotation: AnimatedScalar,
746    last_status: Option<Status>,
747}
748
749impl<P: text::Paragraph> State<P> {
750    fn new() -> Self {
751        Self {
752            menu: menu_overlay::State::default(),
753            keyboard_modifiers: keyboard::Modifiers::default(),
754            is_open: bool::default(),
755            hovered_option: Option::default(),
756            options: Vec::new(),
757            placeholder: paragraph::Plain::default(),
758            label: paragraph::Plain::default(),
759            handle_rotation: AnimatedScalar::new(menu_handle_rotation_target(false)),
760            last_status: None,
761        }
762    }
763
764    fn set_open(&mut self, is_open: bool, now: Instant) {
765        set_menu_open(
766            &mut self.is_open,
767            &mut self.handle_rotation,
768            &mut self.last_status,
769            is_open,
770            now,
771        );
772
773        if is_open {
774            self.menu.reverse_open(now);
775        } else {
776            self.menu.start_close(now);
777        }
778    }
779
780    fn is_animating(&self) -> bool {
781        self.handle_rotation.is_animating() || self.menu.is_animating()
782    }
783
784    fn advance(&mut self, now: Instant) -> bool {
785        self.handle_rotation.advance(now) | self.menu.advance(now)
786    }
787}
788
789fn select_status(is_open: bool, is_hovered: bool) -> Status {
790    if is_open {
791        Status::Opened { is_hovered }
792    } else if is_hovered {
793        Status::Hovered
794    } else {
795        Status::Active
796    }
797}
798
799fn select_label_color(theme: &Theme, status: Status) -> Color {
800    let colors = theme.colors();
801
802    match status {
803        Status::Opened { .. } => colors.primary.color,
804        Status::Hovered => colors.surface.text,
805        Status::Active => colors.surface.text_variant,
806    }
807}
808
809fn menu_handle_rotation_target(is_open: bool) -> f32 {
810    if is_open { 1.0 } else { 0.0 }
811}
812
813fn set_menu_open(
814    open_state: &mut bool,
815    handle_rotation: &mut AnimatedScalar,
816    last_status: &mut Option<Status>,
817    is_open: bool,
818    now: Instant,
819) {
820    let target = menu_handle_rotation_target(is_open);
821
822    let _ = handle_rotation.advance(now);
823
824    *open_state = is_open;
825    *last_status = None;
826    handle_rotation.set_target(
827        target,
828        now,
829        duration_ms(MENU_HANDLE_ROTATION_DURATION_MS),
830        tokens::motion::EASING_STANDARD,
831    );
832}
833
834fn draw_default_handle<Renderer>(
835    renderer: &mut Renderer,
836    center: Point,
837    size: f32,
838    progress: f32,
839    color: Color,
840) where
841    Renderer: geometry::Renderer,
842{
843    if size <= 0.0 {
844        return;
845    }
846
847    let top_left = Point::new(center.x - size / 2.0, center.y - size / 2.0);
848    let mut frame = Frame::new(renderer, Size::new(size, size));
849    let origin = Point::new(size / 2.0, size / 2.0);
850
851    frame.with_save(|frame| {
852        frame.translate(Vector::new(origin.x, origin.y));
853        frame.rotate(menu_handle_rotation_radians(progress));
854        frame.translate(Vector::new(-origin.x, -origin.y));
855        frame.fill(&default_handle_arrow_path(size), color);
856    });
857
858    renderer.with_translation(Vector::new(top_left.x, top_left.y), |renderer| {
859        renderer.draw_geometry(frame.into_geometry());
860    });
861}
862
863fn menu_handle_rotation_radians(progress: f32) -> f32 {
864    PI * progress.clamp(0.0, 1.0)
865}
866
867fn default_handle_arrow_path(size: f32) -> Path {
868    let [left, tip, right] = default_handle_arrow_points(size);
869
870    Path::new(|path| {
871        path.move_to(left);
872        path.line_to(tip);
873        path.line_to(right);
874        path.close();
875    })
876}
877
878fn default_handle_arrow_points(size: f32) -> [Point; 3] {
879    [
880        material_icon_point(MENU_HANDLE_ARROW_LEFT_X, MENU_HANDLE_ARROW_TOP_Y, size),
881        material_icon_point(MENU_HANDLE_ARROW_CENTER_X, MENU_HANDLE_ARROW_BOTTOM_Y, size),
882        material_icon_point(MENU_HANDLE_ARROW_RIGHT_X, MENU_HANDLE_ARROW_TOP_Y, size),
883    ]
884}
885
886fn material_icon_point(x: f32, y: f32, size: f32) -> Point {
887    Point::new(
888        x / MENU_HANDLE_VIEWPORT_SIZE * size,
889        y / MENU_HANDLE_VIEWPORT_SIZE * size,
890    )
891}
892
893impl<P: text::Paragraph> Default for State<P> {
894    fn default() -> Self {
895        Self::new()
896    }
897}
898
899fn find_next<'a, T: PartialEq>(
900    selected: &'a T,
901    mut options: impl Iterator<Item = &'a T>,
902) -> Option<&'a T> {
903    let _ = options.find(|&option| option == selected);
904
905    options.next()
906}
907
908pub(crate) fn menu_option_padding() -> Padding {
909    let vertical = (tokens::component::select::MENU_LIST_ITEM_CONTAINER_HEIGHT
910        - tokens::component::text_field::INPUT_TEXT_LINE_HEIGHT)
911        / 2.0;
912
913    Padding {
914        top: vertical,
915        right: tokens::component::text_field::TRAILING_SPACE,
916        bottom: vertical,
917        left: tokens::component::text_field::LEADING_SPACE,
918    }
919}
920
921pub(crate) fn material_menu_height(option_count: usize) -> Length {
922    let visible_options = option_count.clamp(1, MAX_VISIBLE_OPTIONS) as f32;
923
924    Length::Fixed(tokens::component::select::MENU_LIST_ITEM_CONTAINER_HEIGHT * visible_options)
925}
926
927pub(crate) fn resolved_menu_height(
928    menu_height: Length,
929    intrinsic_height: f32,
930    viewport_height: f32,
931) -> f32 {
932    match menu_height {
933        Length::Fixed(height) => height,
934        Length::Shrink => intrinsic_height,
935        Length::Fill | Length::FillPortion(_) => viewport_height,
936    }
937}
938
939pub(crate) fn prefer_down_when_menu_fits(
940    position: Point,
941    viewport: Rectangle,
942    target_height: f32,
943    menu_height: f32,
944) -> MenuAnchor {
945    let down_anchor_y = position.y + target_height;
946    let space_below = viewport.height - down_anchor_y;
947
948    if space_below < menu_height {
949        return MenuAnchor {
950            position,
951            target_height,
952        };
953    }
954
955    if space_below > position.y {
956        return MenuAnchor {
957            position,
958            target_height,
959        };
960    }
961
962    let adjusted_y = position.y.min((space_below - DIRECTION_EPSILON).max(0.0));
963
964    MenuAnchor {
965        position: Point::new(position.x, adjusted_y),
966        target_height: down_anchor_y - adjusted_y,
967    }
968}
969
970#[cfg(test)]
971#[path = "../../../tests/widget/component/select.rs"]
972mod tests;