Skip to main content

iced_shadcn/
select.rs

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                    // Draw background for selected item
1293                    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                    // Draw hover background (on top of selected if both)
1309                    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}