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                    state.menu.start_open(self.options.borrow().len(), now);
414
415                    if let Some(on_open) = &self.on_open {
416                        shell.publish(on_open.clone());
417                    }
418
419                    shell.capture_event();
420                }
421            }
422            Event::Mouse(mouse::Event::WheelScrolled {
423                delta: mouse::ScrollDelta::Lines { y, .. },
424            }) if state.keyboard_modifiers.command()
425                && cursor.is_over(layout.bounds())
426                && !state.is_open =>
427            {
428                let options = self.options.borrow();
429                let selected = self.selected.as_ref().map(Borrow::borrow);
430
431                let next_option = if *y < 0.0 {
432                    if let Some(selected) = selected {
433                        find_next(selected, options.iter())
434                    } else {
435                        options.first()
436                    }
437                } else if *y > 0.0 {
438                    if let Some(selected) = selected {
439                        find_next(selected, options.iter().rev())
440                    } else {
441                        options.last()
442                    }
443                } else {
444                    None
445                };
446
447                if let Some(next_option) = next_option {
448                    shell.publish((self.on_select)(next_option.clone()));
449                }
450
451                shell.capture_event();
452            }
453            Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
454                state.keyboard_modifiers = *modifiers;
455            }
456            _ => {}
457        };
458
459        let now = match event {
460            Event::Window(window::Event::RedrawRequested(now)) => Some(*now),
461            _ => None,
462        };
463
464        if let Some(now) = now
465            && state.advance(now)
466        {
467            shell.request_redraw();
468        }
469
470        let status = select_status(state.is_open, cursor.is_over(layout.bounds()));
471
472        if state.last_status != Some(status) {
473            state.last_status = Some(status);
474            shell.request_redraw();
475        } else if state.is_animating() {
476            shell.request_redraw();
477        }
478    }
479
480    fn mouse_interaction(
481        &self,
482        _tree: &Tree,
483        layout: Layout<'_>,
484        cursor: mouse::Cursor,
485        _viewport: &Rectangle,
486        _renderer: &Renderer,
487    ) -> mouse::Interaction {
488        if cursor.is_over(layout.bounds()) {
489            mouse::Interaction::Pointer
490        } else {
491            mouse::Interaction::default()
492        }
493    }
494
495    fn draw(
496        &self,
497        tree: &Tree,
498        renderer: &mut Renderer,
499        theme: &Theme,
500        _style: &renderer::Style,
501        layout: Layout<'_>,
502        cursor: mouse::Cursor,
503        viewport: &Rectangle,
504    ) {
505        let font = self.font.unwrap_or_else(|| renderer.default_font());
506        let selected = self.selected.as_ref().map(Borrow::borrow);
507        let state = tree.state.downcast_ref::<State<Renderer::Paragraph>>();
508
509        let bounds = layout.bounds();
510        let status = state
511            .last_status
512            .unwrap_or_else(|| select_status(state.is_open, cursor.is_over(bounds)));
513
514        let style = <Theme as iced_select::Catalog>::style(theme, &self.class, status);
515
516        let label_width = self
517            .label
518            .as_ref()
519            .map(|_| state.label.min_width())
520            .unwrap_or(0.0);
521        let label_x = bounds.x + tokens::component::text_field::LEADING_SPACE;
522        let label_notch = self.label.as_ref().and_then(|_| {
523            text_field_floating_label_notch(bounds, label_x, label_width, label_width, 1.0)
524        });
525
526        draw_text_field_outline(
527            renderer,
528            bounds,
529            style.background,
530            style.border,
531            label_notch,
532        );
533
534        let text_handle = match &self.handle {
535            Handle::Arrow { size } => {
536                let size = size.unwrap_or(Pixels(tokens::component::select::TRAILING_ICON_SIZE));
537                let right = bounds.x + bounds.width - self.field_padding.right;
538                let center = Point::new(right - size.0 / 2.0, bounds.center_y());
539
540                draw_default_handle(
541                    renderer,
542                    center,
543                    size.0,
544                    state.handle_rotation.value,
545                    style.handle_color,
546                );
547
548                None
549            }
550            Handle::Static(Icon {
551                font,
552                code_point,
553                size,
554                line_height,
555                shaping,
556            }) => Some((*font, *code_point, *size, *line_height, *shaping)),
557            Handle::Dynamic { open, closed } => {
558                if state.is_open {
559                    Some((
560                        open.font,
561                        open.code_point,
562                        open.size,
563                        open.line_height,
564                        open.shaping,
565                    ))
566                } else {
567                    Some((
568                        closed.font,
569                        closed.code_point,
570                        closed.size,
571                        closed.line_height,
572                        closed.shaping,
573                    ))
574                }
575            }
576            Handle::None => None,
577        };
578
579        if let Some((font, code_point, size, line_height, shaping)) = text_handle {
580            let size = size.unwrap_or_else(|| renderer.default_size());
581
582            renderer.fill_text(
583                Text {
584                    content: code_point.to_string(),
585                    size,
586                    line_height,
587                    font,
588                    bounds: Size::new(bounds.width, f32::from(line_height.to_absolute(size))),
589                    align_x: text::Alignment::Right,
590                    align_y: alignment::Vertical::Center,
591                    shaping,
592                    wrapping: text::Wrapping::default(),
593                },
594                Point::new(
595                    bounds.x + bounds.width - self.field_padding.right,
596                    bounds.center_y(),
597                ),
598                style.handle_color,
599                *viewport,
600            );
601        }
602
603        let label = selected.map(ToString::to_string);
604
605        if let Some(label) = label.or_else(|| self.placeholder.clone()) {
606            let text_size = self.text_size.unwrap_or_else(|| renderer.default_size());
607
608            renderer.fill_text(
609                Text {
610                    content: label,
611                    size: text_size,
612                    line_height: self.text_line_height,
613                    font,
614                    bounds: Size::new(
615                        bounds.width - self.field_padding.x(),
616                        f32::from(self.text_line_height.to_absolute(text_size)),
617                    ),
618                    align_x: text::Alignment::Default,
619                    align_y: alignment::Vertical::Center,
620                    shaping: self.text_shaping,
621                    wrapping: text::Wrapping::default(),
622                },
623                Point::new(bounds.x + self.field_padding.left, bounds.center_y()),
624                if selected.is_some() {
625                    style.text_color
626                } else {
627                    style.placeholder_color
628                },
629                *viewport,
630            );
631        }
632
633        if let Some(label) = &self.label {
634            let label_size = Pixels(tokens::component::text_field::LABEL_TEXT_POPULATED_SIZE);
635            let label_line_height = text::LineHeight::Absolute(Pixels(
636                tokens::component::text_field::LABEL_TEXT_POPULATED_LINE_HEIGHT,
637            ));
638            let label_height = f32::from(label_line_height.to_absolute(label_size));
639            let label_y = bounds.y;
640
641            renderer.fill_text(
642                Text {
643                    content: label.clone(),
644                    size: label_size,
645                    line_height: label_line_height,
646                    font,
647                    bounds: Size::new(label_width, label_height),
648                    align_x: text::Alignment::Default,
649                    align_y: alignment::Vertical::Center,
650                    shaping: self.text_shaping,
651                    wrapping: text::Wrapping::None,
652                },
653                Point::new(label_x, label_y),
654                select_label_color(theme, status),
655                *viewport,
656            );
657        }
658    }
659
660    fn overlay<'b>(
661        &'b mut self,
662        tree: &'b mut Tree,
663        layout: Layout<'_>,
664        renderer: &Renderer,
665        viewport: &Rectangle,
666        translation: Vector,
667    ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
668        let state = tree.state.downcast_mut::<State<Renderer::Paragraph>>();
669        let font = self.font.unwrap_or_else(|| renderer.default_font());
670
671        if state.is_open {
672            let bounds = layout.bounds();
673            let on_select = &self.on_select;
674            let menu_state = &mut state.menu;
675            let hovered_option = &mut state.hovered_option;
676            let open_state = &mut state.is_open;
677            let handle_rotation = &mut state.handle_rotation;
678            let last_status = &mut state.last_status;
679
680            let mut menu = menu_overlay::Menu::new(
681                menu_state,
682                self.options.borrow(),
683                hovered_option,
684                |option| {
685                    let now = Instant::now();
686
687                    set_menu_open(open_state, handle_rotation, last_status, false, now);
688
689                    (on_select)(option)
690                },
691                None,
692                &self.menu_class,
693            )
694            .width(bounds.width)
695            .padding(self.option_padding)
696            .font(font)
697            .text_shaping(self.text_shaping);
698
699            if let Some(text_size) = self.text_size {
700                menu = menu.text_size(text_size);
701            }
702
703            let anchor = prefer_down_when_menu_fits(
704                layout.position() + translation,
705                *viewport,
706                bounds.height,
707                resolved_menu_height(
708                    self.menu_height,
709                    self.intrinsic_menu_height(renderer),
710                    viewport.height,
711                ),
712            );
713
714            Some(menu.overlay(
715                anchor.position,
716                *viewport,
717                anchor.target_height,
718                self.menu_height,
719            ))
720        } else {
721            None
722        }
723    }
724}
725
726impl<'a, T, L, V, Message, Renderer> From<Select<'a, T, L, V, Message, Renderer>>
727    for Element<'a, Message, Theme, Renderer>
728where
729    T: Clone + ToString + PartialEq + 'a,
730    L: Borrow<[T]> + 'a,
731    V: Borrow<T> + 'a,
732    Message: Clone + 'a,
733    Renderer: text::Renderer + geometry::Renderer + 'a,
734{
735    fn from(select: Select<'a, T, L, V, Message, Renderer>) -> Self {
736        Self::new(select)
737    }
738}
739
740#[derive(Debug)]
741struct State<P: text::Paragraph> {
742    menu: menu_overlay::State,
743    keyboard_modifiers: keyboard::Modifiers,
744    is_open: bool,
745    hovered_option: Option<usize>,
746    options: Vec<paragraph::Plain<P>>,
747    placeholder: paragraph::Plain<P>,
748    label: paragraph::Plain<P>,
749    handle_rotation: AnimatedScalar,
750    last_status: Option<Status>,
751}
752
753impl<P: text::Paragraph> State<P> {
754    fn new() -> Self {
755        Self {
756            menu: menu_overlay::State::default(),
757            keyboard_modifiers: keyboard::Modifiers::default(),
758            is_open: bool::default(),
759            hovered_option: Option::default(),
760            options: Vec::new(),
761            placeholder: paragraph::Plain::default(),
762            label: paragraph::Plain::default(),
763            handle_rotation: AnimatedScalar::new(menu_handle_rotation_target(false)),
764            last_status: None,
765        }
766    }
767
768    fn set_open(&mut self, is_open: bool, now: Instant) {
769        set_menu_open(
770            &mut self.is_open,
771            &mut self.handle_rotation,
772            &mut self.last_status,
773            is_open,
774            now,
775        );
776    }
777
778    fn is_animating(&self) -> bool {
779        self.handle_rotation.is_animating()
780    }
781
782    fn advance(&mut self, now: Instant) -> bool {
783        self.handle_rotation.advance(now)
784    }
785}
786
787fn select_status(is_open: bool, is_hovered: bool) -> Status {
788    if is_open {
789        Status::Opened { is_hovered }
790    } else if is_hovered {
791        Status::Hovered
792    } else {
793        Status::Active
794    }
795}
796
797fn select_label_color(theme: &Theme, status: Status) -> Color {
798    let colors = theme.colors();
799
800    match status {
801        Status::Opened { .. } => colors.primary.color,
802        Status::Hovered => colors.surface.text,
803        Status::Active => colors.surface.text_variant,
804    }
805}
806
807fn menu_handle_rotation_target(is_open: bool) -> f32 {
808    if is_open { 1.0 } else { 0.0 }
809}
810
811fn set_menu_open(
812    open_state: &mut bool,
813    handle_rotation: &mut AnimatedScalar,
814    last_status: &mut Option<Status>,
815    is_open: bool,
816    now: Instant,
817) {
818    let target = menu_handle_rotation_target(is_open);
819
820    let _ = handle_rotation.advance(now);
821
822    *open_state = is_open;
823    *last_status = None;
824    handle_rotation.set_target(
825        target,
826        now,
827        duration_ms(MENU_HANDLE_ROTATION_DURATION_MS),
828        tokens::motion::EASING_STANDARD,
829    );
830}
831
832fn draw_default_handle<Renderer>(
833    renderer: &mut Renderer,
834    center: Point,
835    size: f32,
836    progress: f32,
837    color: Color,
838) where
839    Renderer: geometry::Renderer,
840{
841    if size <= 0.0 {
842        return;
843    }
844
845    let top_left = Point::new(center.x - size / 2.0, center.y - size / 2.0);
846    let mut frame = Frame::new(renderer, Size::new(size, size));
847    let origin = Point::new(size / 2.0, size / 2.0);
848
849    frame.with_save(|frame| {
850        frame.translate(Vector::new(origin.x, origin.y));
851        frame.rotate(menu_handle_rotation_radians(progress));
852        frame.translate(Vector::new(-origin.x, -origin.y));
853        frame.fill(&default_handle_arrow_path(size), color);
854    });
855
856    renderer.with_translation(Vector::new(top_left.x, top_left.y), |renderer| {
857        renderer.draw_geometry(frame.into_geometry());
858    });
859}
860
861fn menu_handle_rotation_radians(progress: f32) -> f32 {
862    PI * progress.clamp(0.0, 1.0)
863}
864
865fn default_handle_arrow_path(size: f32) -> Path {
866    let [left, tip, right] = default_handle_arrow_points(size);
867
868    Path::new(|path| {
869        path.move_to(left);
870        path.line_to(tip);
871        path.line_to(right);
872        path.close();
873    })
874}
875
876fn default_handle_arrow_points(size: f32) -> [Point; 3] {
877    [
878        material_icon_point(MENU_HANDLE_ARROW_LEFT_X, MENU_HANDLE_ARROW_TOP_Y, size),
879        material_icon_point(MENU_HANDLE_ARROW_CENTER_X, MENU_HANDLE_ARROW_BOTTOM_Y, size),
880        material_icon_point(MENU_HANDLE_ARROW_RIGHT_X, MENU_HANDLE_ARROW_TOP_Y, size),
881    ]
882}
883
884fn material_icon_point(x: f32, y: f32, size: f32) -> Point {
885    Point::new(
886        x / MENU_HANDLE_VIEWPORT_SIZE * size,
887        y / MENU_HANDLE_VIEWPORT_SIZE * size,
888    )
889}
890
891impl<P: text::Paragraph> Default for State<P> {
892    fn default() -> Self {
893        Self::new()
894    }
895}
896
897fn find_next<'a, T: PartialEq>(
898    selected: &'a T,
899    mut options: impl Iterator<Item = &'a T>,
900) -> Option<&'a T> {
901    let _ = options.find(|&option| option == selected);
902
903    options.next()
904}
905
906pub(crate) fn menu_option_padding() -> Padding {
907    let vertical = (tokens::component::select::MENU_LIST_ITEM_CONTAINER_HEIGHT
908        - tokens::component::text_field::INPUT_TEXT_LINE_HEIGHT)
909        / 2.0;
910
911    Padding {
912        top: vertical,
913        right: tokens::component::text_field::TRAILING_SPACE,
914        bottom: vertical,
915        left: tokens::component::text_field::LEADING_SPACE,
916    }
917}
918
919pub(crate) fn material_menu_height(option_count: usize) -> Length {
920    let visible_options = option_count.clamp(1, MAX_VISIBLE_OPTIONS) as f32;
921
922    Length::Fixed(tokens::component::select::MENU_LIST_ITEM_CONTAINER_HEIGHT * visible_options)
923}
924
925pub(crate) fn resolved_menu_height(
926    menu_height: Length,
927    intrinsic_height: f32,
928    viewport_height: f32,
929) -> f32 {
930    match menu_height {
931        Length::Fixed(height) => height,
932        Length::Shrink => intrinsic_height,
933        Length::Fill | Length::FillPortion(_) => viewport_height,
934    }
935}
936
937pub(crate) fn prefer_down_when_menu_fits(
938    position: Point,
939    viewport: Rectangle,
940    target_height: f32,
941    menu_height: f32,
942) -> MenuAnchor {
943    let down_anchor_y = position.y + target_height;
944    let space_below = viewport.height - down_anchor_y;
945
946    if space_below < menu_height {
947        return MenuAnchor {
948            position,
949            target_height,
950        };
951    }
952
953    if space_below > position.y {
954        return MenuAnchor {
955            position,
956            target_height,
957        };
958    }
959
960    let adjusted_y = position.y.min((space_below - DIRECTION_EPSILON).max(0.0));
961
962    MenuAnchor {
963        position: Point::new(position.x, adjusted_y),
964        target_height: down_anchor_y - adjusted_y,
965    }
966}
967
968#[cfg(test)]
969#[path = "../../../tests/widget/component/select.rs"]
970mod tests;