1use iced::advanced::layout;
2use iced::advanced::renderer;
3use iced::advanced::text;
4use iced::advanced::text::paragraph;
5use iced::advanced::widget::Tree;
6use iced::advanced::{Clipboard, Layout, Shell, Widget};
7use iced::alignment;
8use iced::border::Border;
9use iced::keyboard;
10use iced::mouse;
11use iced::touch;
12use iced::window;
13use iced::{
14 Background, Color, Element, Event, Font, Length, Padding, Pixels, Point, Rectangle, Shadow,
15 Size, Vector,
16};
17use lucide_icons::Icon as LucideIcon;
18
19use crate::button::ButtonRadius;
20use crate::overlay::{keyboard as overlay_keyboard, positioning};
21use crate::theme::Theme as ShadcnTheme;
22use crate::tokens::{
23 AccentColor, accent_color, accent_foreground, accent_high, accent_soft, accent_text, is_dark,
24};
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum SelectSize {
28 Size1,
29 Size2,
30 Size3,
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum TriggerVariant {
35 Classic,
36 Surface,
37 Soft,
38 Ghost,
39}
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum ContentVariant {
43 Solid,
44 Soft,
45}
46
47#[derive(Clone, Copy, Debug)]
48pub struct SelectProps {
49 pub size: SelectSize,
50 pub variant: TriggerVariant,
51 pub content_variant: ContentVariant,
52 pub color: AccentColor,
53 pub content_color: Option<AccentColor>,
54 pub radius: Option<ButtonRadius>,
55 pub high_contrast: bool,
56 pub disabled: bool,
57}
58
59impl Default for SelectProps {
60 fn default() -> Self {
61 Self {
62 size: SelectSize::Size2,
63 variant: TriggerVariant::Surface,
64 content_variant: ContentVariant::Solid,
65 color: AccentColor::Gray,
66 content_color: None,
67 radius: None,
68 high_contrast: false,
69 disabled: false,
70 }
71 }
72}
73
74impl SelectProps {
75 pub fn new() -> Self {
76 Self::default()
77 }
78
79 pub fn size(mut self, size: SelectSize) -> Self {
80 self.size = size;
81 self
82 }
83
84 pub fn variant(mut self, variant: TriggerVariant) -> Self {
85 self.variant = variant;
86 self
87 }
88
89 pub fn content_variant(mut self, content_variant: ContentVariant) -> Self {
90 self.content_variant = content_variant;
91 self
92 }
93
94 pub fn color(mut self, color: AccentColor) -> Self {
95 self.color = color;
96 self
97 }
98
99 pub fn content_color(mut self, color: AccentColor) -> Self {
100 self.content_color = Some(color);
101 self
102 }
103
104 pub fn radius(mut self, radius: ButtonRadius) -> Self {
105 self.radius = Some(radius);
106 self
107 }
108
109 pub fn high_contrast(mut self, high_contrast: bool) -> Self {
110 self.high_contrast = high_contrast;
111 self
112 }
113
114 pub fn disabled(mut self, disabled: bool) -> Self {
115 self.disabled = disabled;
116 self
117 }
118}
119
120#[derive(Clone, Debug)]
121pub struct SelectItem<T> {
122 pub value: T,
123 pub label: String,
124 pub disabled: bool,
125}
126
127impl<T> SelectItem<T> {
128 pub fn new(value: T, label: impl Into<String>) -> Self {
129 Self {
130 value,
131 label: label.into(),
132 disabled: false,
133 }
134 }
135
136 pub fn disabled(mut self, disabled: bool) -> Self {
137 self.disabled = disabled;
138 self
139 }
140}
141
142#[derive(Clone, Debug)]
143pub struct SelectGroup<T> {
144 pub label: Option<String>,
145 pub items: Vec<SelectItem<T>>,
146}
147
148impl<T> SelectGroup<T> {
149 pub fn new(label: impl Into<String>, items: Vec<SelectItem<T>>) -> Self {
150 Self {
151 label: Some(label.into()),
152 items,
153 }
154 }
155
156 pub fn unnamed(items: Vec<SelectItem<T>>) -> Self {
157 Self { label: None, items }
158 }
159}
160
161#[derive(Clone, Debug)]
162pub enum SelectEntry<T> {
163 Item(SelectItem<T>),
164 Label(String),
165 Separator,
166 Group(SelectGroup<T>),
167}
168
169impl<T> SelectEntry<T> {
170 pub fn label(label: impl Into<String>) -> Self {
171 Self::Label(label.into())
172 }
173
174 pub fn separator() -> Self {
175 Self::Separator
176 }
177}
178
179impl<T> From<SelectItem<T>> for SelectEntry<T> {
180 fn from(item: SelectItem<T>) -> Self {
181 Self::Item(item)
182 }
183}
184
185impl<T> From<SelectGroup<T>> for SelectEntry<T> {
186 fn from(group: SelectGroup<T>) -> Self {
187 Self::Group(group)
188 }
189}
190
191#[derive(Clone, Copy, Debug)]
192struct SelectMetrics {
193 trigger_height: f32,
194 trigger_padding_x: f32,
195 trigger_padding_y: f32,
196 text_size: u32,
197 chevron_size: f32,
198 check_size: f32,
199 icon_gap: f32,
200 item_height: f32,
201 item_padding_left: f32,
202 item_padding_right: f32,
203 label_text_size: u32,
204 label_padding_y: f32,
205 content_padding: f32,
206 separator_height: f32,
207 separator_margin_y: f32,
208 indicator_size: f32,
209 scroll_button_height: f32,
210}
211
212impl SelectSize {
213 fn metrics(self) -> SelectMetrics {
214 match self {
215 SelectSize::Size1 => SelectMetrics {
216 trigger_height: 32.0,
217 trigger_padding_x: 12.0,
218 trigger_padding_y: 6.0,
219 text_size: 14,
220 chevron_size: 16.0,
221 check_size: 16.0,
222 icon_gap: 8.0,
223 item_height: 32.0,
224 item_padding_left: 8.0,
225 item_padding_right: 32.0,
226 label_text_size: 12,
227 label_padding_y: 6.0,
228 content_padding: 4.0,
229 separator_height: 1.0,
230 separator_margin_y: 4.0,
231 indicator_size: 14.0,
232 scroll_button_height: 24.0,
233 },
234 SelectSize::Size2 => SelectMetrics {
235 trigger_height: 36.0,
236 trigger_padding_x: 12.0,
237 trigger_padding_y: 8.0,
238 text_size: 14,
239 chevron_size: 16.0,
240 check_size: 16.0,
241 icon_gap: 8.0,
242 item_height: 32.0,
243 item_padding_left: 8.0,
244 item_padding_right: 32.0,
245 label_text_size: 12,
246 label_padding_y: 6.0,
247 content_padding: 4.0,
248 separator_height: 1.0,
249 separator_margin_y: 4.0,
250 indicator_size: 14.0,
251 scroll_button_height: 24.0,
252 },
253 SelectSize::Size3 => SelectMetrics {
254 trigger_height: 40.0,
255 trigger_padding_x: 14.0,
256 trigger_padding_y: 10.0,
257 text_size: 16,
258 chevron_size: 16.0,
259 check_size: 16.0,
260 icon_gap: 10.0,
261 item_height: 36.0,
262 item_padding_left: 8.0,
263 item_padding_right: 32.0,
264 label_text_size: 12,
265 label_padding_y: 6.0,
266 content_padding: 4.0,
267 separator_height: 1.0,
268 separator_margin_y: 4.0,
269 indicator_size: 14.0,
270 scroll_button_height: 24.0,
271 },
272 }
273 }
274}
275
276#[derive(Clone, Copy)]
277enum SelectEntries<'a, T> {
278 Plain(&'a [T]),
279 Entries(&'a [SelectEntry<T>]),
280}
281
282pub fn select<'a, Message: Clone + 'a, T, F>(
283 options: &'a [T],
284 selected: Option<T>,
285 placeholder: &'a str,
286 on_select: F,
287 props: SelectProps,
288 theme: &ShadcnTheme,
289) -> Select<'a, T, Message>
290where
291 T: Clone + PartialEq + ToString + 'a,
292 F: Fn(T) -> Message + 'a,
293{
294 Select::new(
295 SelectEntries::Plain(options),
296 selected,
297 placeholder,
298 on_select,
299 props,
300 theme,
301 )
302}
303
304pub fn select_entries<'a, Message: Clone + 'a, T, F>(
305 entries: &'a [SelectEntry<T>],
306 selected: Option<T>,
307 placeholder: &'a str,
308 on_select: F,
309 props: SelectProps,
310 theme: &ShadcnTheme,
311) -> Select<'a, T, Message>
312where
313 T: Clone + PartialEq + ToString + 'a,
314 F: Fn(T) -> Message + 'a,
315{
316 Select::new(
317 SelectEntries::Entries(entries),
318 selected,
319 placeholder,
320 on_select,
321 props,
322 theme,
323 )
324}
325
326pub struct Select<'a, T, Message> {
327 entries: SelectEntries<'a, T>,
328 selected: Option<T>,
329 placeholder: Option<String>,
330 on_select: Box<dyn Fn(T) -> Message + 'a>,
331 props: SelectProps,
332 theme: ShadcnTheme,
333 width: Length,
334 menu_height: Length,
335 on_open: Option<Message>,
336 on_close: Option<Message>,
337 font: Option<Font>,
338 text_line_height: text::LineHeight,
339 text_shaping: text::Shaping,
340 last_status: Option<SelectStatus>,
341}
342
343impl<'a, T, Message> Select<'a, T, Message>
344where
345 T: Clone + PartialEq + ToString + 'a,
346 Message: Clone + 'a,
347{
348 fn new<F>(
349 entries: SelectEntries<'a, T>,
350 selected: Option<T>,
351 placeholder: &'a str,
352 on_select: F,
353 props: SelectProps,
354 theme: &ShadcnTheme,
355 ) -> Self
356 where
357 F: Fn(T) -> Message + 'a,
358 {
359 Self {
360 entries,
361 selected,
362 placeholder: Some(placeholder.to_string()),
363 on_select: Box::new(on_select),
364 props,
365 theme: theme.clone(),
366 width: Length::Shrink,
367 menu_height: Length::Shrink,
368 on_open: None,
369 on_close: None,
370 font: None,
371 text_line_height: text::LineHeight::default(),
372 text_shaping: text::Shaping::Basic,
373 last_status: None,
374 }
375 }
376
377 pub fn width(mut self, width: impl Into<Length>) -> Self {
378 self.width = width.into();
379 self
380 }
381
382 pub fn menu_height(mut self, height: impl Into<Length>) -> Self {
383 self.menu_height = height.into();
384 self
385 }
386
387 pub fn on_open(mut self, on_open: Message) -> Self {
388 self.on_open = Some(on_open);
389 self
390 }
391
392 pub fn on_close(mut self, on_close: Message) -> Self {
393 self.on_close = Some(on_close);
394 self
395 }
396
397 pub fn font(mut self, font: Font) -> Self {
398 self.font = Some(font);
399 self
400 }
401
402 pub fn text_line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
403 self.text_line_height = line_height.into();
404 self
405 }
406
407 pub fn text_shaping(mut self, shaping: text::Shaping) -> Self {
408 self.text_shaping = shaping;
409 self
410 }
411}
412
413impl<Message, AppTheme, Renderer, T> Widget<Message, AppTheme, Renderer> for Select<'_, T, Message>
414where
415 T: Clone + PartialEq + ToString,
416 Message: Clone,
417 Renderer: renderer::Renderer + text::Renderer<Font = Font>,
418{
419 fn tag(&self) -> iced::advanced::widget::tree::Tag {
420 iced::advanced::widget::tree::Tag::of::<SelectState<Renderer::Paragraph>>()
421 }
422
423 fn state(&self) -> iced::advanced::widget::tree::State {
424 iced::advanced::widget::tree::State::new(SelectState::<Renderer::Paragraph>::new())
425 }
426
427 fn size(&self) -> Size<Length> {
428 let metrics = self.props.size.metrics();
429 Size {
430 width: self.width,
431 height: Length::Fixed(metrics.trigger_height),
432 }
433 }
434
435 fn layout(
436 &mut self,
437 tree: &mut Tree,
438 renderer: &Renderer,
439 limits: &layout::Limits,
440 ) -> layout::Node {
441 let state = tree
442 .state
443 .downcast_mut::<SelectState<Renderer::Paragraph>>();
444 let metrics = self.props.size.metrics();
445 let font = self.font.unwrap_or_else(|| renderer.default_font());
446 let text_size: Pixels = metrics.text_size.into();
447 let labels = collect_item_labels(self.entries.clone());
448
449 state
450 .option_labels
451 .resize_with(labels.len(), Default::default);
452
453 let option_text = text::Text {
454 content: "",
455 bounds: Size::new(
456 f32::INFINITY,
457 self.text_line_height.to_absolute(text_size).into(),
458 ),
459 size: text_size,
460 line_height: self.text_line_height,
461 font,
462 align_x: text::Alignment::Default,
463 align_y: alignment::Vertical::Center,
464 shaping: self.text_shaping,
465 wrapping: text::Wrapping::default(),
466 };
467
468 for (label, paragraph) in labels.iter().zip(state.option_labels.iter_mut()) {
469 let _ = paragraph.update(text::Text {
470 content: label,
471 ..option_text
472 });
473 }
474
475 if let Some(placeholder) = &self.placeholder {
476 let _ = state.placeholder.update(text::Text {
477 content: placeholder,
478 ..option_text
479 });
480 }
481
482 let max_width = match self.width {
483 Length::Shrink => {
484 let labels_width = state.option_labels.iter().fold(0.0, |width, paragraph| {
485 f32::max(width, paragraph.min_width())
486 });
487 labels_width.max(
488 self.placeholder
489 .as_ref()
490 .map(|_| state.placeholder.min_width())
491 .unwrap_or(0.0),
492 )
493 }
494 _ => 0.0,
495 };
496
497 let padding = trigger_padding(metrics);
498 let intrinsic = Size::new(
499 max_width + padding.left + padding.right,
500 metrics.trigger_height,
501 );
502
503 layout::Node::new(limits.resolve(
504 self.width,
505 Length::Fixed(metrics.trigger_height),
506 intrinsic,
507 ))
508 }
509
510 fn update(
511 &mut self,
512 tree: &mut Tree,
513 event: &Event,
514 layout: Layout<'_>,
515 cursor: mouse::Cursor,
516 _renderer: &Renderer,
517 _clipboard: &mut dyn Clipboard,
518 shell: &mut Shell<'_, Message>,
519 _viewport: &Rectangle,
520 ) {
521 let state = tree
522 .state
523 .downcast_mut::<SelectState<Renderer::Paragraph>>();
524 let disabled = self.props.disabled;
525
526 if disabled && state.is_open {
527 state.is_open = false;
528 }
529
530 if !disabled {
531 match event {
532 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
533 | Event::Touch(touch::Event::FingerPressed { .. }) => {
534 if state.is_open {
535 let over_trigger = cursor.is_over(layout.bounds());
536 let over_menu = state
537 .menu
538 .menu_bounds
539 .map(|bounds| cursor.is_over(bounds))
540 .unwrap_or(false);
541
542 if over_trigger || !over_menu {
543 state.is_open = false;
544 if let Some(on_close) = &self.on_close {
545 shell.publish(on_close.clone());
546 }
547 shell.capture_event();
548 }
549 } else if cursor.is_over(layout.bounds()) {
550 state.is_open = true;
551 state.hovered_row = selected_row_index(
552 self.entries.clone(),
553 self.selected.as_ref(),
554 self.props.size.metrics(),
555 );
556 if let Some(on_open) = &self.on_open {
557 shell.publish(on_open.clone());
558 }
559 shell.capture_event();
560 }
561 }
562 Event::Keyboard(keyboard::Event::KeyPressed { .. })
563 if matches!(
564 overlay_keyboard::command(event),
565 Some(overlay_keyboard::OverlayCommand::Close)
566 ) =>
567 {
568 if state.is_open {
569 state.is_open = false;
570 if let Some(on_close) = &self.on_close {
571 shell.publish(on_close.clone());
572 }
573 shell.capture_event();
574 }
575 }
576 Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
577 state.keyboard_modifiers = *modifiers;
578 }
579 _ => {}
580 }
581 }
582
583 if !state.is_open {
584 state.menu.menu_bounds = None;
585 }
586
587 let status = if disabled {
588 SelectStatus::Disabled
589 } else {
590 let is_hovered = cursor.is_over(layout.bounds());
591 if state.is_open {
592 SelectStatus::Opened { is_hovered }
593 } else if is_hovered {
594 SelectStatus::Hovered
595 } else {
596 SelectStatus::Active
597 }
598 };
599
600 if let Event::Window(window::Event::RedrawRequested(_now)) = event {
601 self.last_status = Some(status);
602 } else if self.last_status.is_some_and(|last| last != status) {
603 shell.request_redraw();
604 }
605 }
606
607 fn mouse_interaction(
608 &self,
609 _tree: &Tree,
610 layout: Layout<'_>,
611 cursor: mouse::Cursor,
612 _viewport: &Rectangle,
613 _renderer: &Renderer,
614 ) -> mouse::Interaction {
615 if self.props.disabled {
616 return mouse::Interaction::default();
617 }
618
619 if cursor.is_over(layout.bounds()) {
620 mouse::Interaction::Pointer
621 } else {
622 mouse::Interaction::default()
623 }
624 }
625
626 fn draw(
627 &self,
628 _tree: &Tree,
629 renderer: &mut Renderer,
630 _theme: &AppTheme,
631 _style: &renderer::Style,
632 layout: Layout<'_>,
633 _cursor: mouse::Cursor,
634 viewport: &Rectangle,
635 ) {
636 let bounds = layout.bounds();
637 if !bounds.intersects(viewport) {
638 return;
639 }
640
641 let metrics = self.props.size.metrics();
642 let font = self.font.unwrap_or_else(|| renderer.default_font());
643 let status = self.last_status.unwrap_or(SelectStatus::Active);
644 let trigger_style = select_trigger_style(&self.theme, self.props, status);
645 let padding = trigger_padding(metrics);
646
647 renderer.fill_quad(
648 renderer::Quad {
649 bounds,
650 border: trigger_style.border,
651 shadow: trigger_style.shadow,
652 ..renderer::Quad::default()
653 },
654 trigger_style.background,
655 );
656
657 let chevron = LucideIcon::ChevronDown;
658 renderer.fill_text(
659 text::Text {
660 content: char::from(chevron).to_string(),
661 size: metrics.chevron_size.into(),
662 line_height: text::LineHeight::Absolute(metrics.chevron_size.into()),
663 font: Font::with_name("lucide"),
664 bounds: Size::new(bounds.width, metrics.trigger_height),
665 align_x: text::Alignment::Right,
666 align_y: alignment::Vertical::Center,
667 shaping: text::Shaping::Basic,
668 wrapping: text::Wrapping::default(),
669 },
670 Point::new(bounds.x + bounds.width - padding.right, bounds.center_y()),
671 trigger_style.handle_color,
672 *viewport,
673 );
674
675 let selected_label = selected_label(self.entries.clone(), self.selected.as_ref());
676 let label = selected_label.or_else(|| self.placeholder.clone());
677
678 if let Some(label) = label {
679 let text_size: Pixels = metrics.text_size.into();
680 let text_bounds = Size::new(
681 (bounds.width - padding.left - padding.right).max(0.0),
682 self.text_line_height.to_absolute(text_size).into(),
683 );
684
685 renderer.fill_text(
686 text::Text {
687 content: label,
688 size: text_size,
689 line_height: self.text_line_height,
690 font,
691 bounds: text_bounds,
692 align_x: text::Alignment::Default,
693 align_y: alignment::Vertical::Center,
694 shaping: self.text_shaping,
695 wrapping: text::Wrapping::default(),
696 },
697 Point::new(bounds.x + padding.left, bounds.center_y()),
698 if self.selected.is_some() {
699 trigger_style.text_color
700 } else {
701 trigger_style.placeholder_color
702 },
703 *viewport,
704 );
705 }
706 }
707
708 fn overlay<'b>(
709 &'b mut self,
710 tree: &'b mut Tree,
711 layout: Layout<'_>,
712 renderer: &Renderer,
713 viewport: &Rectangle,
714 translation: Vector,
715 ) -> Option<iced::overlay::Element<'b, Message, AppTheme, Renderer>> {
716 let state = tree
717 .state
718 .downcast_mut::<SelectState<Renderer::Paragraph>>();
719 let font = self.font.unwrap_or_else(|| renderer.default_font());
720
721 if state.is_open {
722 let bounds = layout.bounds();
723 let on_select = &self.on_select;
724 let props = self.props;
725 let metrics = self.props.size.metrics();
726
727 let menu = SelectMenu {
728 state: &mut state.menu,
729 entries: self.entries.clone(),
730 hovered_row: &mut state.hovered_row,
731 selected: self.selected.clone(),
732 on_selected: Box::new(|option| {
733 state.is_open = false;
734 (on_select)(option)
735 }),
736 props,
737 metrics,
738 font,
739 text_shaping: self.text_shaping,
740 theme: self.theme.clone(),
741 width: bounds.width,
742 };
743
744 Some(menu.overlay::<Renderer, AppTheme>(
745 layout.position() + translation,
746 *viewport,
747 bounds.width,
748 bounds.height,
749 self.menu_height,
750 ))
751 } else {
752 None
753 }
754 }
755}
756
757impl<'a, T, Message, AppTheme, Renderer> From<Select<'a, T, Message>>
758 for Element<'a, Message, AppTheme, Renderer>
759where
760 T: Clone + PartialEq + ToString + 'a,
761 Message: Clone + 'a,
762 Renderer: renderer::Renderer + text::Renderer<Font = Font> + 'a,
763{
764 fn from(select: Select<'a, T, Message>) -> Element<'a, Message, AppTheme, Renderer> {
765 Element::new(select)
766 }
767}
768
769#[derive(Debug)]
770struct SelectState<P: text::Paragraph> {
771 menu: SelectMenuState,
772 keyboard_modifiers: keyboard::Modifiers,
773 is_open: bool,
774 hovered_row: Option<usize>,
775 option_labels: Vec<paragraph::Plain<P>>,
776 placeholder: paragraph::Plain<P>,
777}
778
779impl<P: text::Paragraph> SelectState<P> {
780 fn new() -> Self {
781 Self {
782 menu: SelectMenuState::default(),
783 keyboard_modifiers: keyboard::Modifiers::default(),
784 is_open: false,
785 hovered_row: None,
786 option_labels: Vec::new(),
787 placeholder: paragraph::Plain::default(),
788 }
789 }
790}
791
792impl<P: text::Paragraph> Default for SelectState<P> {
793 fn default() -> Self {
794 Self::new()
795 }
796}
797
798#[derive(Debug)]
799struct SelectMenuState {
800 tree: Tree,
801 menu_bounds: Option<Rectangle>,
802}
803
804impl SelectMenuState {
805 fn new() -> Self {
806 Self {
807 tree: Tree::empty(),
808 menu_bounds: None,
809 }
810 }
811}
812
813impl Default for SelectMenuState {
814 fn default() -> Self {
815 Self::new()
816 }
817}
818
819#[derive(Debug, Clone, Copy, PartialEq, Eq)]
820enum SelectStatus {
821 Active,
822 Hovered,
823 Opened { is_hovered: bool },
824 Disabled,
825}
826
827struct SelectMenu<'a, T, Message> {
828 state: &'a mut SelectMenuState,
829 entries: SelectEntries<'a, T>,
830 hovered_row: &'a mut Option<usize>,
831 selected: Option<T>,
832 on_selected: Box<dyn FnMut(T) -> Message + 'a>,
833 props: SelectProps,
834 metrics: SelectMetrics,
835 font: Font,
836 text_shaping: text::Shaping,
837 theme: ShadcnTheme,
838 width: f32,
839}
840
841impl<'a, T, Message> SelectMenu<'a, T, Message>
842where
843 T: Clone + PartialEq + ToString,
844 Message: Clone + 'a,
845{
846 fn overlay<Renderer, AppTheme>(
847 self,
848 position: Point,
849 viewport: Rectangle,
850 target_width: f32,
851 target_height: f32,
852 menu_height: Length,
853 ) -> iced::overlay::Element<'a, Message, AppTheme, Renderer>
854 where
855 Renderer: renderer::Renderer + text::Renderer<Font = Font>,
856 {
857 iced::overlay::Element::new(Box::new(SelectOverlay::new::<Renderer, AppTheme>(
858 position,
859 viewport,
860 self,
861 target_width,
862 target_height,
863 menu_height,
864 )))
865 }
866}
867
868struct SelectOverlay<'a, T, Message> {
869 position: Point,
870 viewport: Rectangle,
871 tree: &'a mut Tree,
872 list: SelectList<'a, T, Message>,
873 width: f32,
874 target_width: f32,
875 target_height: f32,
876 props: SelectProps,
877 theme: ShadcnTheme,
878 menu_bounds: &'a mut Option<Rectangle>,
879}
880
881impl<'a, T, Message> SelectOverlay<'a, T, Message>
882where
883 T: Clone + PartialEq + ToString,
884 Message: Clone + 'a,
885{
886 fn new<Renderer, AppTheme>(
887 position: Point,
888 viewport: Rectangle,
889 menu: SelectMenu<'a, T, Message>,
890 target_width: f32,
891 target_height: f32,
892 menu_height: Length,
893 ) -> Self
894 where
895 Renderer: renderer::Renderer + text::Renderer<Font = Font>,
896 {
897 let SelectMenu {
898 state,
899 entries,
900 hovered_row,
901 selected,
902 on_selected,
903 props,
904 metrics,
905 font,
906 text_shaping,
907 width,
908 theme,
909 } = menu;
910 let width = width.max(128.0);
911 let menu_bounds = &mut state.menu_bounds;
912
913 let list = SelectList {
914 entries,
915 hovered_row,
916 selected,
917 on_selected,
918 props,
919 metrics,
920 font,
921 text_shaping,
922 menu_height,
923 theme: theme.clone(),
924 };
925
926 state
927 .tree
928 .diff::<Message, AppTheme, Renderer>(&list as &dyn Widget<_, _, _>);
929
930 Self {
931 position,
932 viewport,
933 tree: &mut state.tree,
934 list,
935 width,
936 target_width,
937 target_height,
938 props,
939 theme,
940 menu_bounds,
941 }
942 }
943}
944
945impl<Message, AppTheme, Renderer, T> iced::advanced::Overlay<Message, AppTheme, Renderer>
946 for SelectOverlay<'_, T, Message>
947where
948 T: Clone + PartialEq + ToString,
949 Message: Clone,
950 Renderer: renderer::Renderer + text::Renderer<Font = Font>,
951{
952 fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
953 let space_below = bounds.height - (self.position.y + self.target_height);
954 let space_above = self.position.y;
955 let gap = 4.0;
956
957 let limits = layout::Limits::new(
958 Size::ZERO,
959 Size::new(
960 bounds.width - self.position.x,
961 if space_below > space_above {
962 space_below
963 } else {
964 space_above
965 },
966 ),
967 )
968 .width(self.width);
969
970 let node = <SelectList<'_, T, Message> as Widget<Message, AppTheme, Renderer>>::layout(
971 &mut self.list,
972 self.tree,
973 renderer,
974 &limits,
975 );
976 let size = node.size();
977 let placement = positioning::place_overlay_centered(
978 self.position,
979 Size::new(self.target_width, self.target_height),
980 size,
981 bounds,
982 gap,
983 );
984 let node = node.move_to(placement.position);
985
986 *self.menu_bounds = Some(node.bounds());
987
988 node
989 }
990
991 fn update(
992 &mut self,
993 event: &Event,
994 layout: Layout<'_>,
995 cursor: mouse::Cursor,
996 renderer: &Renderer,
997 clipboard: &mut dyn Clipboard,
998 shell: &mut Shell<'_, Message>,
999 ) {
1000 let bounds = layout.bounds();
1001 <SelectList<'_, T, Message> as Widget<Message, AppTheme, Renderer>>::update(
1002 &mut self.list,
1003 self.tree,
1004 event,
1005 layout,
1006 cursor,
1007 renderer,
1008 clipboard,
1009 shell,
1010 &bounds,
1011 );
1012 }
1013
1014 fn mouse_interaction(
1015 &self,
1016 layout: Layout<'_>,
1017 cursor: mouse::Cursor,
1018 renderer: &Renderer,
1019 ) -> mouse::Interaction {
1020 <SelectList<'_, T, Message> as Widget<Message, AppTheme, Renderer>>::mouse_interaction(
1021 &self.list,
1022 self.tree,
1023 layout,
1024 cursor,
1025 &self.viewport,
1026 renderer,
1027 )
1028 }
1029
1030 fn draw(
1031 &self,
1032 renderer: &mut Renderer,
1033 _theme: &AppTheme,
1034 defaults: &renderer::Style,
1035 layout: Layout<'_>,
1036 cursor: mouse::Cursor,
1037 ) {
1038 let bounds = layout.bounds();
1039 let style = select_menu_style(&self.theme, self.props);
1040
1041 renderer.fill_quad(
1042 renderer::Quad {
1043 bounds,
1044 border: style.border,
1045 shadow: style.shadow,
1046 ..renderer::Quad::default()
1047 },
1048 style.background,
1049 );
1050
1051 self.list.draw(
1052 self.tree, renderer, _theme, defaults, layout, cursor, &bounds,
1053 );
1054 }
1055}
1056
1057struct SelectList<'a, T, Message> {
1058 entries: SelectEntries<'a, T>,
1059 hovered_row: &'a mut Option<usize>,
1060 selected: Option<T>,
1061 on_selected: Box<dyn FnMut(T) -> Message + 'a>,
1062 props: SelectProps,
1063 metrics: SelectMetrics,
1064 font: Font,
1065 text_shaping: text::Shaping,
1066 menu_height: Length,
1067 theme: ShadcnTheme,
1068}
1069
1070impl<Message, AppTheme, Renderer, T> Widget<Message, AppTheme, Renderer>
1071 for SelectList<'_, T, Message>
1072where
1073 T: Clone + PartialEq + ToString,
1074 Message: Clone,
1075 Renderer: renderer::Renderer + text::Renderer<Font = Font>,
1076{
1077 fn tag(&self) -> iced::advanced::widget::tree::Tag {
1078 iced::advanced::widget::tree::Tag::of::<SelectListState>()
1079 }
1080
1081 fn state(&self) -> iced::advanced::widget::tree::State {
1082 iced::advanced::widget::tree::State::new(SelectListState::default())
1083 }
1084
1085 fn size(&self) -> Size<Length> {
1086 Size::new(Length::Fill, Length::Shrink)
1087 }
1088
1089 fn layout(
1090 &mut self,
1091 _tree: &mut Tree,
1092 _renderer: &Renderer,
1093 limits: &layout::Limits,
1094 ) -> layout::Node {
1095 let rows = build_rows(self.entries.clone(), self.selected.as_ref(), self.metrics);
1096 let content_height = rows.iter().map(|row| row.height).sum::<f32>();
1097 let intrinsic = Size::new(0.0, content_height + self.metrics.content_padding * 2.0);
1098
1099 layout::Node::new(limits.resolve(Length::Fill, self.menu_height, intrinsic))
1100 }
1101
1102 fn update(
1103 &mut self,
1104 tree: &mut Tree,
1105 event: &Event,
1106 layout: Layout<'_>,
1107 cursor: mouse::Cursor,
1108 _renderer: &Renderer,
1109 _clipboard: &mut dyn Clipboard,
1110 shell: &mut Shell<'_, Message>,
1111 _viewport: &Rectangle,
1112 ) {
1113 let bounds = layout.bounds();
1114 let rows = build_rows(self.entries.clone(), self.selected.as_ref(), self.metrics);
1115 let layout_info = list_layout(bounds, &rows, self.metrics);
1116 let state = tree.state.downcast_mut::<SelectListState>();
1117
1118 if layout_info.max_scroll > 0.0 {
1119 state.scroll_offset = state.scroll_offset.clamp(0.0, layout_info.max_scroll);
1120 } else {
1121 state.scroll_offset = 0.0;
1122 }
1123
1124 match event {
1125 Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => {
1126 let scroll_delta = match delta {
1127 mouse::ScrollDelta::Lines { y, .. } => -y * self.metrics.item_height,
1128 mouse::ScrollDelta::Pixels { y, .. } => -y,
1129 };
1130 if scroll_delta.abs() > f32::EPSILON {
1131 state.scroll_offset =
1132 (state.scroll_offset + scroll_delta).clamp(0.0, layout_info.max_scroll);
1133 shell.request_redraw();
1134 shell.capture_event();
1135 }
1136 }
1137 Event::Mouse(mouse::Event::CursorMoved { .. }) => {
1138 if let Some(cursor_position) = cursor.position_in(layout_info.list_bounds) {
1139 let y = cursor_position.y + state.scroll_offset;
1140 if let Some(index) = row_at(&rows, y) {
1141 if matches!(&rows[index].kind, RowKind::Item { disabled: true, .. }) {
1142 let old_hovered = *self.hovered_row;
1143 *self.hovered_row = None;
1144 if old_hovered.is_some() {
1145 shell.request_redraw();
1146 }
1147 } else {
1148 let old_hovered = *self.hovered_row;
1149 *self.hovered_row = Some(index);
1150 if old_hovered != Some(index) {
1151 shell.request_redraw();
1152 }
1153 }
1154 } else {
1155 let old_hovered = *self.hovered_row;
1156 *self.hovered_row = None;
1157 if old_hovered.is_some() {
1158 shell.request_redraw();
1159 }
1160 }
1161 } else {
1162 let old_hovered = *self.hovered_row;
1163 *self.hovered_row = None;
1164 if old_hovered.is_some() {
1165 shell.request_redraw();
1166 }
1167 }
1168 }
1169 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
1170 | Event::Touch(touch::Event::FingerPressed { .. }) => {
1171 if let Some(bounds) = layout_info.top_button_bounds
1172 && cursor.is_over(bounds)
1173 {
1174 state.scroll_offset = (state.scroll_offset - self.metrics.item_height)
1175 .clamp(0.0, layout_info.max_scroll);
1176 shell.request_redraw();
1177 shell.capture_event();
1178 return;
1179 }
1180
1181 if let Some(bounds) = layout_info.bottom_button_bounds
1182 && cursor.is_over(bounds)
1183 {
1184 state.scroll_offset = (state.scroll_offset + self.metrics.item_height)
1185 .clamp(0.0, layout_info.max_scroll);
1186 shell.request_redraw();
1187 shell.capture_event();
1188 return;
1189 }
1190
1191 if let Some(cursor_position) = cursor.position_in(layout_info.list_bounds) {
1192 let y = cursor_position.y + state.scroll_offset;
1193 if let Some(index) = row_at(&rows, y)
1194 && let RowKind::Item {
1195 value,
1196 disabled: false,
1197 ..
1198 } = &rows[index].kind
1199 {
1200 shell.publish((self.on_selected)(value.clone()));
1201 shell.capture_event();
1202 }
1203 } else if let Some(index) = *self.hovered_row
1204 && let RowKind::Item {
1205 value,
1206 disabled: false,
1207 ..
1208 } = &rows[index].kind
1209 {
1210 shell.publish((self.on_selected)(value.clone()));
1211 shell.capture_event();
1212 }
1213 }
1214 _ => {}
1215 }
1216
1217 if let Event::Window(window::Event::RedrawRequested(_now)) = event {
1218 state.is_hovered = Some(cursor.is_over(bounds));
1219 } else if state
1220 .is_hovered
1221 .is_some_and(|is_hovered| is_hovered != cursor.is_over(bounds))
1222 {
1223 shell.request_redraw();
1224 }
1225 }
1226
1227 fn mouse_interaction(
1228 &self,
1229 _tree: &Tree,
1230 layout: Layout<'_>,
1231 cursor: mouse::Cursor,
1232 _viewport: &Rectangle,
1233 _renderer: &Renderer,
1234 ) -> mouse::Interaction {
1235 let bounds = layout.bounds();
1236 if cursor.is_over(bounds) {
1237 mouse::Interaction::Pointer
1238 } else {
1239 mouse::Interaction::default()
1240 }
1241 }
1242
1243 fn draw(
1244 &self,
1245 tree: &Tree,
1246 renderer: &mut Renderer,
1247 _theme: &AppTheme,
1248 _style: &renderer::Style,
1249 layout: Layout<'_>,
1250 _cursor: mouse::Cursor,
1251 viewport: &Rectangle,
1252 ) {
1253 let bounds = layout.bounds();
1254 if !bounds.intersects(viewport) {
1255 return;
1256 }
1257
1258 let rows = build_rows(self.entries.clone(), self.selected.as_ref(), self.metrics);
1259 let layout_info = list_layout(bounds, &rows, self.metrics);
1260 let state = tree.state.downcast_ref::<SelectListState>();
1261 let scroll_offset = state.scroll_offset.clamp(0.0, layout_info.max_scroll);
1262 let menu_style = select_menu_style(&self.theme, self.props);
1263 let item_radius = item_radius(&self.theme);
1264 let disabled_text_color = apply_opacity(menu_style.text_color, 0.5);
1265
1266 let mut y = layout_info.list_bounds.y - scroll_offset;
1267 for (index, row) in rows.iter().enumerate() {
1268 let row_bounds = Rectangle {
1269 x: layout_info.list_bounds.x,
1270 y,
1271 width: layout_info.list_bounds.width,
1272 height: row.height,
1273 };
1274 y += row.height;
1275
1276 if row_bounds.y > layout_info.list_bounds.y + layout_info.list_bounds.height {
1277 break;
1278 }
1279 if row_bounds.y + row_bounds.height < layout_info.list_bounds.y {
1280 continue;
1281 }
1282
1283 match &row.kind {
1284 RowKind::Item {
1285 label,
1286 disabled,
1287 selected,
1288 ..
1289 } => {
1290 let is_hovered = *self.hovered_row == Some(index);
1291
1292 if *selected && !*disabled {
1294 renderer.fill_quad(
1295 renderer::Quad {
1296 bounds: row_bounds,
1297 border: Border {
1298 radius: item_radius.into(),
1299 width: 0.0,
1300 color: Color::TRANSPARENT,
1301 },
1302 ..renderer::Quad::default()
1303 },
1304 menu_style.selected_background,
1305 );
1306 }
1307
1308 if is_hovered && !*disabled {
1310 renderer.fill_quad(
1311 renderer::Quad {
1312 bounds: row_bounds,
1313 border: Border {
1314 radius: item_radius.into(),
1315 width: 0.0,
1316 color: Color::TRANSPARENT,
1317 },
1318 ..renderer::Quad::default()
1319 },
1320 menu_style.hover_background,
1321 );
1322 }
1323
1324 let text_color = if *disabled {
1325 disabled_text_color
1326 } else if is_hovered {
1327 menu_style.selected_text_color
1328 } else {
1329 menu_style.text_color
1330 };
1331
1332 renderer.fill_text(
1333 text::Text {
1334 content: label.clone(),
1335 size: self.metrics.text_size.into(),
1336 line_height: text::LineHeight::Absolute(
1337 (self.metrics.text_size as f32 + 6.0).into(),
1338 ),
1339 font: self.font,
1340 bounds: Size::new(
1341 (row_bounds.width
1342 - self.metrics.item_padding_left
1343 - self.metrics.item_padding_right)
1344 .max(0.0),
1345 row_bounds.height,
1346 ),
1347 align_x: text::Alignment::Default,
1348 align_y: alignment::Vertical::Center,
1349 shaping: self.text_shaping,
1350 wrapping: text::Wrapping::default(),
1351 },
1352 Point::new(
1353 row_bounds.x + self.metrics.item_padding_left,
1354 row_bounds.center_y(),
1355 ),
1356 text_color,
1357 *viewport,
1358 );
1359
1360 if *selected {
1361 let icon_color = if *disabled {
1362 disabled_text_color
1363 } else if is_hovered {
1364 menu_style.selected_text_color
1365 } else {
1366 menu_style.text_color
1367 };
1368 let icon_center = Point::new(
1369 row_bounds.x + row_bounds.width - self.metrics.item_padding_right / 2.0,
1370 row_bounds.center_y(),
1371 );
1372 renderer.fill_text(
1373 text::Text {
1374 content: char::from(LucideIcon::Check).to_string(),
1375 size: self.metrics.check_size.into(),
1376 line_height: text::LineHeight::Absolute(
1377 self.metrics.check_size.into(),
1378 ),
1379 font: Font::with_name("lucide"),
1380 bounds: Size::new(
1381 self.metrics.indicator_size,
1382 self.metrics.indicator_size,
1383 ),
1384 align_x: text::Alignment::Center,
1385 align_y: alignment::Vertical::Center,
1386 shaping: text::Shaping::Basic,
1387 wrapping: text::Wrapping::default(),
1388 },
1389 icon_center,
1390 icon_color,
1391 *viewport,
1392 );
1393 }
1394 }
1395 RowKind::Label { text: label } => {
1396 renderer.fill_text(
1397 text::Text {
1398 content: label.clone(),
1399 size: self.metrics.label_text_size.into(),
1400 line_height: text::LineHeight::Absolute(
1401 (self.metrics.label_text_size as f32 + 4.0).into(),
1402 ),
1403 font: self.font,
1404 bounds: Size::new(row_bounds.width, row_bounds.height),
1405 align_x: text::Alignment::Default,
1406 align_y: alignment::Vertical::Center,
1407 shaping: self.text_shaping,
1408 wrapping: text::Wrapping::default(),
1409 },
1410 Point::new(
1411 row_bounds.x + self.metrics.item_padding_left,
1412 row_bounds.center_y(),
1413 ),
1414 menu_style.muted_text_color,
1415 *viewport,
1416 );
1417 }
1418 RowKind::Separator => {
1419 let line_bounds = Rectangle {
1420 x: bounds.x,
1421 y: row_bounds.center_y() - self.metrics.separator_height / 2.0,
1422 width: bounds.width,
1423 height: self.metrics.separator_height,
1424 };
1425 renderer.fill_quad(
1426 renderer::Quad {
1427 bounds: line_bounds,
1428 border: Border::default(),
1429 ..renderer::Quad::default()
1430 },
1431 Background::Color(menu_style.separator_color),
1432 );
1433 }
1434 }
1435 }
1436
1437 if layout_info.show_buttons {
1438 let up_enabled = scroll_offset > 0.0;
1439 let down_enabled = scroll_offset < layout_info.max_scroll;
1440 if let Some(bounds) = layout_info.top_button_bounds {
1441 let color = if up_enabled {
1442 menu_style.muted_text_color
1443 } else {
1444 apply_opacity(menu_style.muted_text_color, 0.4)
1445 };
1446 renderer.fill_text(
1447 text::Text {
1448 content: char::from(LucideIcon::ChevronUp).to_string(),
1449 size: self.metrics.chevron_size.into(),
1450 line_height: text::LineHeight::Absolute(self.metrics.chevron_size.into()),
1451 font: Font::with_name("lucide"),
1452 bounds: Size::new(bounds.width, bounds.height),
1453 align_x: text::Alignment::Center,
1454 align_y: alignment::Vertical::Center,
1455 shaping: text::Shaping::Basic,
1456 wrapping: text::Wrapping::default(),
1457 },
1458 Point::new(bounds.center_x(), bounds.center_y()),
1459 color,
1460 *viewport,
1461 );
1462 }
1463
1464 if let Some(bounds) = layout_info.bottom_button_bounds {
1465 let color = if down_enabled {
1466 menu_style.muted_text_color
1467 } else {
1468 apply_opacity(menu_style.muted_text_color, 0.4)
1469 };
1470 renderer.fill_text(
1471 text::Text {
1472 content: char::from(LucideIcon::ChevronDown).to_string(),
1473 size: self.metrics.chevron_size.into(),
1474 line_height: text::LineHeight::Absolute(self.metrics.chevron_size.into()),
1475 font: Font::with_name("lucide"),
1476 bounds: Size::new(bounds.width, bounds.height),
1477 align_x: text::Alignment::Center,
1478 align_y: alignment::Vertical::Center,
1479 shaping: text::Shaping::Basic,
1480 wrapping: text::Wrapping::default(),
1481 },
1482 Point::new(bounds.center_x(), bounds.center_y()),
1483 color,
1484 *viewport,
1485 );
1486 }
1487 }
1488 }
1489}
1490
1491#[derive(Debug, Default)]
1492struct SelectListState {
1493 scroll_offset: f32,
1494 is_hovered: Option<bool>,
1495}
1496
1497struct Row<T> {
1498 kind: RowKind<T>,
1499 height: f32,
1500}
1501
1502enum RowKind<T> {
1503 Item {
1504 value: T,
1505 label: String,
1506 disabled: bool,
1507 selected: bool,
1508 },
1509 Label {
1510 text: String,
1511 },
1512 Separator,
1513}
1514
1515struct ListLayout {
1516 show_buttons: bool,
1517 list_bounds: Rectangle,
1518 top_button_bounds: Option<Rectangle>,
1519 bottom_button_bounds: Option<Rectangle>,
1520 max_scroll: f32,
1521}
1522
1523fn select_radius(theme: &ShadcnTheme, props: SelectProps) -> f32 {
1524 match props.radius {
1525 Some(ButtonRadius::None) => 0.0,
1526 Some(ButtonRadius::Small) => theme.radius.sm,
1527 Some(ButtonRadius::Medium) => theme.radius.md,
1528 Some(ButtonRadius::Large) => theme.radius.lg,
1529 Some(ButtonRadius::Full) => 9999.0,
1530 None => theme.radius.md,
1531 }
1532}
1533
1534fn trigger_padding(metrics: SelectMetrics) -> Padding {
1535 Padding {
1536 top: metrics.trigger_padding_y,
1537 bottom: metrics.trigger_padding_y,
1538 left: metrics.trigger_padding_x,
1539 right: metrics.trigger_padding_x + metrics.chevron_size + metrics.icon_gap,
1540 }
1541}
1542
1543fn collect_item_labels<T: ToString>(entries: SelectEntries<'_, T>) -> Vec<String> {
1544 let mut labels = Vec::new();
1545 collect_labels(entries, &mut labels);
1546 labels
1547}
1548
1549fn collect_labels<T: ToString>(entries: SelectEntries<'_, T>, labels: &mut Vec<String>) {
1550 match entries {
1551 SelectEntries::Plain(options) => {
1552 for option in options {
1553 labels.push(option.to_string());
1554 }
1555 }
1556 SelectEntries::Entries(entries) => {
1557 for entry in entries {
1558 match entry {
1559 SelectEntry::Item(item) => labels.push(item.label.clone()),
1560 SelectEntry::Group(group) => {
1561 for item in &group.items {
1562 labels.push(item.label.clone());
1563 }
1564 }
1565 SelectEntry::Label(_) | SelectEntry::Separator => {}
1566 }
1567 }
1568 }
1569 }
1570}
1571
1572fn selected_label<T: PartialEq + ToString>(
1573 entries: SelectEntries<'_, T>,
1574 selected: Option<&T>,
1575) -> Option<String> {
1576 let selected = selected?;
1577 match entries {
1578 SelectEntries::Plain(_) => Some(selected.to_string()),
1579 SelectEntries::Entries(entries) => {
1580 for entry in entries {
1581 match entry {
1582 SelectEntry::Item(item) => {
1583 if &item.value == selected {
1584 return Some(item.label.clone());
1585 }
1586 }
1587 SelectEntry::Group(group) => {
1588 for item in &group.items {
1589 if &item.value == selected {
1590 return Some(item.label.clone());
1591 }
1592 }
1593 }
1594 SelectEntry::Label(_) | SelectEntry::Separator => {}
1595 }
1596 }
1597 None
1598 }
1599 }
1600}
1601
1602fn build_rows<T: Clone + PartialEq + ToString>(
1603 entries: SelectEntries<'_, T>,
1604 selected: Option<&T>,
1605 metrics: SelectMetrics,
1606) -> Vec<Row<T>> {
1607 let mut rows = Vec::new();
1608 let label_line_height = metrics.label_text_size as f32 + 4.0;
1609 match entries {
1610 SelectEntries::Plain(options) => {
1611 for option in options {
1612 let is_selected = selected == Some(option);
1613 rows.push(Row {
1614 kind: RowKind::Item {
1615 value: option.clone(),
1616 label: option.to_string(),
1617 disabled: false,
1618 selected: is_selected,
1619 },
1620 height: metrics.item_height,
1621 });
1622 }
1623 }
1624 SelectEntries::Entries(entries) => {
1625 for entry in entries {
1626 match entry {
1627 SelectEntry::Item(item) => {
1628 let is_selected = selected == Some(&item.value);
1629 rows.push(Row {
1630 kind: RowKind::Item {
1631 value: item.value.clone(),
1632 label: item.label.clone(),
1633 disabled: item.disabled,
1634 selected: is_selected,
1635 },
1636 height: metrics.item_height,
1637 });
1638 }
1639 SelectEntry::Label(label) => rows.push(Row {
1640 kind: RowKind::Label {
1641 text: label.clone(),
1642 },
1643 height: metrics.label_padding_y * 2.0 + label_line_height,
1644 }),
1645 SelectEntry::Separator => rows.push(Row {
1646 kind: RowKind::Separator,
1647 height: metrics.separator_margin_y * 2.0 + metrics.separator_height,
1648 }),
1649 SelectEntry::Group(group) => {
1650 if let Some(label) = &group.label {
1651 rows.push(Row {
1652 kind: RowKind::Label {
1653 text: label.clone(),
1654 },
1655 height: metrics.label_padding_y * 2.0 + label_line_height,
1656 });
1657 }
1658 for item in &group.items {
1659 let is_selected = selected == Some(&item.value);
1660 rows.push(Row {
1661 kind: RowKind::Item {
1662 value: item.value.clone(),
1663 label: item.label.clone(),
1664 disabled: item.disabled,
1665 selected: is_selected,
1666 },
1667 height: metrics.item_height,
1668 });
1669 }
1670 }
1671 }
1672 }
1673 }
1674 }
1675 rows
1676}
1677
1678fn selected_row_index<T: Clone + PartialEq + ToString>(
1679 entries: SelectEntries<'_, T>,
1680 selected: Option<&T>,
1681 metrics: SelectMetrics,
1682) -> Option<usize> {
1683 let rows = build_rows(entries, selected, metrics);
1684 rows.iter()
1685 .position(|row| matches!(&row.kind, RowKind::Item { selected: true, .. }))
1686}
1687
1688fn row_at<T>(rows: &[Row<T>], y: f32) -> Option<usize> {
1689 let mut offset = 0.0;
1690 for (index, row) in rows.iter().enumerate() {
1691 if y >= offset && y < offset + row.height {
1692 return Some(index);
1693 }
1694 offset += row.height;
1695 }
1696 None
1697}
1698
1699fn list_layout<T>(bounds: Rectangle, rows: &[Row<T>], metrics: SelectMetrics) -> ListLayout {
1700 let content_height = rows.iter().map(|row| row.height).sum::<f32>();
1701 let padding = metrics.content_padding;
1702 let available_height = (bounds.height - padding * 2.0).max(0.0);
1703 let mut show_buttons = false;
1704 let mut list_height = available_height;
1705
1706 if content_height > available_height {
1707 show_buttons = true;
1708 list_height = (available_height - metrics.scroll_button_height * 2.0).max(0.0);
1709 }
1710
1711 let list_bounds = Rectangle {
1712 x: bounds.x + padding,
1713 y: bounds.y
1714 + padding
1715 + if show_buttons {
1716 metrics.scroll_button_height
1717 } else {
1718 0.0
1719 },
1720 width: (bounds.width - padding * 2.0).max(0.0),
1721 height: list_height,
1722 };
1723
1724 let top_button_bounds = show_buttons.then_some(Rectangle {
1725 x: bounds.x,
1726 y: bounds.y,
1727 width: bounds.width,
1728 height: metrics.scroll_button_height,
1729 });
1730
1731 let bottom_button_bounds = show_buttons.then_some(Rectangle {
1732 x: bounds.x,
1733 y: bounds.y + bounds.height - metrics.scroll_button_height,
1734 width: bounds.width,
1735 height: metrics.scroll_button_height,
1736 });
1737
1738 let max_scroll = (content_height - list_height).max(0.0);
1739
1740 ListLayout {
1741 show_buttons,
1742 list_bounds,
1743 top_button_bounds,
1744 bottom_button_bounds,
1745 max_scroll,
1746 }
1747}
1748
1749#[derive(Clone, Copy, Debug)]
1750struct TriggerStyle {
1751 text_color: Color,
1752 placeholder_color: Color,
1753 handle_color: Color,
1754 background: Background,
1755 border: Border,
1756 shadow: Shadow,
1757}
1758
1759#[derive(Clone, Copy, Debug)]
1760struct MenuStyle {
1761 background: Background,
1762 border: Border,
1763 text_color: Color,
1764 muted_text_color: Color,
1765 selected_text_color: Color,
1766 selected_background: Background,
1767 hover_background: Background,
1768 separator_color: Color,
1769 shadow: Shadow,
1770}
1771
1772fn select_trigger_style(
1773 theme: &ShadcnTheme,
1774 props: SelectProps,
1775 status: SelectStatus,
1776) -> TriggerStyle {
1777 let palette = theme.palette;
1778 let radius = select_radius(theme, props);
1779 let accent = accent_color(&palette, props.color);
1780 let accent_text_color = accent_text(&palette, props.color);
1781 let soft_bg = accent_soft(&palette, props.color);
1782 let dark_mode = is_dark(&palette);
1783 let base_bg = if dark_mode {
1784 Background::Color(apply_opacity(palette.input, 0.3))
1785 } else {
1786 Background::Color(Color::TRANSPARENT)
1787 };
1788
1789 let mut background = match props.variant {
1790 TriggerVariant::Soft => Background::Color(soft_bg),
1791 TriggerVariant::Ghost => Background::Color(Color::TRANSPARENT),
1792 TriggerVariant::Classic | TriggerVariant::Surface => base_bg,
1793 };
1794 let mut border_color = match props.variant {
1795 TriggerVariant::Soft | TriggerVariant::Ghost => Color::TRANSPARENT,
1796 TriggerVariant::Classic | TriggerVariant::Surface => palette.input,
1797 };
1798 let mut text_color = match props.variant {
1799 TriggerVariant::Soft | TriggerVariant::Ghost => accent_text_color,
1800 _ => palette.foreground,
1801 };
1802 let mut placeholder_color = match props.variant {
1803 TriggerVariant::Soft | TriggerVariant::Ghost => apply_opacity(accent_text_color, 0.6),
1804 _ => palette.muted_foreground,
1805 };
1806 let mut handle_color = match props.variant {
1807 TriggerVariant::Soft | TriggerVariant::Ghost => apply_opacity(accent_text_color, 0.6),
1808 _ => apply_opacity(palette.muted_foreground, 0.5),
1809 };
1810 let mut shadow = match props.variant {
1811 TriggerVariant::Ghost => Shadow::default(),
1812 _ => shadow_xs(1.0),
1813 };
1814
1815 match status {
1816 SelectStatus::Hovered | SelectStatus::Opened { .. } => match props.variant {
1817 TriggerVariant::Soft => {
1818 background = Background::Color(mix(soft_bg, accent, 0.1));
1819 }
1820 TriggerVariant::Ghost => {
1821 background = Background::Color(apply_opacity(soft_bg, 0.5));
1822 }
1823 TriggerVariant::Classic | TriggerVariant::Surface => {
1824 if dark_mode {
1825 background = Background::Color(apply_opacity(palette.input, 0.5));
1826 }
1827 }
1828 },
1829 SelectStatus::Disabled => {
1830 if let Background::Color(color) = background {
1831 background = Background::Color(apply_opacity(color, 0.5));
1832 }
1833 border_color = apply_opacity(border_color, 0.5);
1834 text_color = apply_opacity(text_color, 0.5);
1835 placeholder_color = apply_opacity(placeholder_color, 0.5);
1836 handle_color = apply_opacity(handle_color, 0.5);
1837 shadow = match props.variant {
1838 TriggerVariant::Ghost => Shadow::default(),
1839 _ => shadow_xs(0.5),
1840 };
1841 }
1842 SelectStatus::Active => {}
1843 }
1844
1845 TriggerStyle {
1846 text_color,
1847 placeholder_color,
1848 handle_color,
1849 background,
1850 border: Border {
1851 radius: radius.into(),
1852 width: 1.0,
1853 color: border_color,
1854 },
1855 shadow,
1856 }
1857}
1858
1859fn select_menu_style(theme: &ShadcnTheme, props: SelectProps) -> MenuStyle {
1860 let palette = theme.palette;
1861 let radius = select_radius(theme, props);
1862 let content_color = props.content_color.unwrap_or(props.color);
1863 let is_gray = matches!(content_color, AccentColor::Gray);
1864 let accent = if is_gray {
1865 palette.accent
1866 } else {
1867 accent_color(&palette, content_color)
1868 };
1869 let accent_fg = if is_gray {
1870 palette.accent_foreground
1871 } else {
1872 accent_foreground(&palette, content_color)
1873 };
1874 let accent_soft_bg = if is_gray {
1875 palette.accent
1876 } else {
1877 accent_soft(&palette, content_color)
1878 };
1879 let accent_strong = if is_gray {
1880 palette.foreground
1881 } else {
1882 accent_high(&palette, content_color)
1883 };
1884
1885 let background = match props.content_variant {
1886 ContentVariant::Soft => Background::Color(accent_soft_bg),
1887 ContentVariant::Solid => Background::Color(palette.popover),
1888 };
1889 let mut selected_background = match props.content_variant {
1890 ContentVariant::Soft => {
1891 let blend = if is_gray { palette.foreground } else { accent };
1892 let mix_ratio = if is_gray { 0.08 } else { 0.2 };
1893 Background::Color(mix(accent_soft_bg, blend, mix_ratio))
1894 }
1895 ContentVariant::Solid => Background::Color(accent),
1896 };
1897 let mut selected_text_color = accent_fg;
1898
1899 if props.high_contrast {
1900 selected_background = Background::Color(accent_strong);
1901 selected_text_color = palette.background;
1902 }
1903
1904 let shadow = shadow_md(1.0);
1905
1906 let hover_background = match props.content_variant {
1907 ContentVariant::Soft => {
1908 let blend = if is_gray { palette.foreground } else { accent };
1909 let mix_ratio = if is_gray { 0.12 } else { 0.25 };
1910 Background::Color(mix(accent_soft_bg, blend, mix_ratio))
1911 }
1912 ContentVariant::Solid => Background::Color(palette.accent),
1913 };
1914
1915 MenuStyle {
1916 background,
1917 border: Border {
1918 width: 1.0,
1919 radius: radius.into(),
1920 color: palette.border,
1921 },
1922 text_color: palette.popover_foreground,
1923 muted_text_color: palette.muted_foreground,
1924 selected_text_color,
1925 selected_background,
1926 hover_background,
1927 separator_color: palette.border,
1928 shadow,
1929 }
1930}
1931
1932fn item_radius(theme: &ShadcnTheme) -> f32 {
1933 theme.radius.sm
1934}
1935
1936use crate::tokens::mix;
1937
1938fn apply_opacity(color: Color, opacity: f32) -> Color {
1939 Color {
1940 a: (color.a * opacity).clamp(0.0, 1.0),
1941 ..color
1942 }
1943}
1944
1945fn shadow_xs(opacity: f32) -> Shadow {
1946 Shadow {
1947 color: apply_opacity(Color::BLACK, 0.05 * opacity),
1948 offset: Vector::new(0.0, 1.0),
1949 blur_radius: 2.0,
1950 }
1951}
1952
1953fn shadow_md(opacity: f32) -> Shadow {
1954 Shadow {
1955 color: apply_opacity(Color::BLACK, 0.1 * opacity),
1956 offset: Vector::new(0.0, 4.0),
1957 blur_radius: 6.0,
1958 }
1959}