1use 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
48pub 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
78pub 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 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 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
155 self.placeholder = Some(placeholder.into());
156 self
157 }
158
159 pub fn label(mut self, label: impl Into<String>) -> Self {
161 self.label = Some(label.into());
162 self
163 }
164
165 pub fn width(mut self, width: impl Into<Length>) -> Self {
167 self.width = width.into();
168 self
169 }
170
171 pub fn menu_height(mut self, menu_height: impl Into<Length>) -> Self {
173 self.menu_height = menu_height.into();
174 self
175 }
176
177 pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
179 self.field_padding = padding.into();
180 self
181 }
182
183 pub fn option_padding(mut self, padding: impl Into<Padding>) -> Self {
185 self.option_padding = padding.into();
186 self
187 }
188
189 pub fn text_size(mut self, size: impl Into<Pixels>) -> Self {
191 self.text_size = Some(size.into());
192 self
193 }
194
195 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 pub fn text_shaping(mut self, shaping: text::Shaping) -> Self {
203 self.text_shaping = shaping;
204 self
205 }
206
207 pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
209 self.font = Some(font.into());
210 self
211 }
212
213 pub fn handle(mut self, handle: Handle<Renderer::Font>) -> Self {
215 self.handle = handle;
216 self
217 }
218
219 pub fn on_open(mut self, on_open: Message) -> Self {
221 self.on_open = Some(on_open);
222 self
223 }
224
225 pub fn on_close(mut self, on_close: Message) -> Self {
227 self.on_close = Some(on_close);
228 self
229 }
230
231 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 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;