Skip to main content

armas_basic/components/
select.rs

1//! Select Component
2//!
3//! Searchable dropdown menu styled like shadcn/ui Select.
4//! Features:
5//! - Keyboard navigation (arrow keys, enter, escape)
6//! - Search/filter functionality
7//! - Support for icons and descriptions
8//! - Disabled options
9//! - State persistence
10
11use crate::ext::ArmasContextExt;
12use crate::Theme;
13use egui::{
14    vec2, Color32, CornerRadius, Key, Painter, Rect, Response, Sense, Stroke, TextEdit, Ui,
15};
16
17// ============================================================================
18// Constants
19// ============================================================================
20
21const TRIGGER_HEIGHT: f32 = 36.0;
22const ITEM_HEIGHT: f32 = 32.0;
23const ITEM_HEIGHT_WITH_DESC: f32 = 48.0;
24const CORNER_RADIUS: u8 = 6;
25const CORNER_RADIUS_SM: u8 = 4;
26const PADDING: f32 = 8.0;
27const ICON_WIDTH: f32 = 24.0;
28
29// ============================================================================
30// SelectOption
31// ============================================================================
32
33/// A selectable option in a dropdown
34#[derive(Clone, Debug)]
35pub struct SelectOption {
36    /// Option value (internal identifier)
37    pub value: String,
38    /// Option label (displayed text)
39    pub label: String,
40    /// Optional icon identifier
41    pub icon: Option<String>,
42    /// Optional description text
43    pub description: Option<String>,
44    /// Whether this option is disabled
45    pub disabled: bool,
46}
47
48impl SelectOption {
49    /// Create a new select option
50    pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
51        Self {
52            value: value.into(),
53            label: label.into(),
54            icon: None,
55            description: None,
56            disabled: false,
57        }
58    }
59
60    /// Set an icon for this option
61    #[must_use]
62    pub fn icon(mut self, icon: impl Into<String>) -> Self {
63        self.icon = Some(icon.into());
64        self
65    }
66
67    /// Set a description for this option
68    #[must_use]
69    pub fn description(mut self, description: impl Into<String>) -> Self {
70        self.description = Some(description.into());
71        self
72    }
73
74    /// Set whether this option is disabled
75    #[must_use]
76    pub const fn disabled(mut self, disabled: bool) -> Self {
77        self.disabled = disabled;
78        self
79    }
80}
81
82// ============================================================================
83// Select
84// ============================================================================
85
86/// Searchable dropdown select component
87///
88/// # Example
89///
90/// ```rust,no_run
91/// # use egui::Ui;
92/// # fn example(ui: &mut Ui) {
93/// use armas_basic::components::{Select, SelectOption};
94///
95/// let options = vec![
96///     SelectOption::new("apple", "Apple"),
97///     SelectOption::new("banana", "Banana"),
98///     SelectOption::new("cherry", "Cherry"),
99/// ];
100/// let mut select = Select::new(options)
101///     .selected("apple")
102///     .placeholder("Pick a fruit...");
103/// select.show(ui);
104/// # }
105/// ```
106pub struct Select {
107    id: Option<egui::Id>,
108    options: Vec<SelectOption>,
109    selected_value: Option<String>,
110    is_open: bool,
111    search_text: String,
112    filtered_indices: Vec<usize>,
113    highlighted_index: Option<usize>,
114    label: Option<String>,
115    placeholder: String,
116    width: Option<f32>,
117    custom_height: Option<f32>,
118    max_height: f32,
119    searchable: bool,
120    custom_font_size: Option<f32>,
121    custom_corner_radius: Option<u8>,
122    custom_padding_x: Option<f32>,
123}
124
125impl Select {
126    /// Create a new Select component with the given options
127    #[must_use]
128    pub fn new(options: Vec<SelectOption>) -> Self {
129        let filtered_indices: Vec<usize> = (0..options.len()).collect();
130        Self {
131            id: None,
132            options,
133            selected_value: None,
134            is_open: false,
135            search_text: String::new(),
136            filtered_indices,
137            highlighted_index: None,
138            label: None,
139            placeholder: "Select an option...".to_string(),
140            width: None,
141            custom_height: None,
142            max_height: 300.0,
143            searchable: true,
144            custom_font_size: None,
145            custom_corner_radius: None,
146            custom_padding_x: None,
147        }
148    }
149
150    /// Build a Select using a closure-based API (prefer using `Select::new()`)
151    pub fn build(builder: impl FnOnce(&mut SelectBuilder)) -> Self {
152        let mut select_builder = SelectBuilder {
153            options: Vec::new(),
154        };
155        builder(&mut select_builder);
156        Self::new(select_builder.options)
157    }
158
159    /// Set a unique identifier for this select component
160    #[must_use]
161    pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
162        self.id = Some(id.into());
163        self
164    }
165
166    /// Set the initially selected value
167    #[must_use]
168    pub fn selected(mut self, value: impl Into<String>) -> Self {
169        self.selected_value = Some(value.into());
170        self
171    }
172
173    /// Set a label for the select component
174    #[must_use]
175    pub fn label(mut self, label: impl Into<String>) -> Self {
176        self.label = Some(label.into());
177        self
178    }
179
180    /// Set placeholder text shown when no option is selected
181    #[must_use]
182    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
183        self.placeholder = placeholder.into();
184        self
185    }
186
187    /// Set the width of the select component
188    #[must_use]
189    pub const fn width(mut self, width: f32) -> Self {
190        self.width = Some(width);
191        self
192    }
193
194    /// Set the trigger height (overrides default 36px)
195    #[must_use]
196    pub const fn height(mut self, height: f32) -> Self {
197        self.custom_height = Some(height);
198        self
199    }
200
201    /// Set the maximum height of the dropdown menu
202    #[must_use]
203    pub const fn max_height(mut self, height: f32) -> Self {
204        self.max_height = height;
205        self
206    }
207
208    /// Enable or disable search functionality
209    #[must_use]
210    pub const fn searchable(mut self, searchable: bool) -> Self {
211        self.searchable = searchable;
212        self
213    }
214
215    /// Set font size (overrides height-derived default)
216    #[must_use]
217    pub const fn font_size(mut self, size: f32) -> Self {
218        self.custom_font_size = Some(size);
219        self
220    }
221
222    /// Set corner radius (overrides default 6)
223    #[must_use]
224    pub const fn corner_radius(mut self, radius: u8) -> Self {
225        self.custom_corner_radius = Some(radius);
226        self
227    }
228
229    /// Set horizontal padding (overrides height-derived default)
230    #[must_use]
231    pub const fn padding_x(mut self, padding: f32) -> Self {
232        self.custom_padding_x = Some(padding);
233        self
234    }
235
236    /// Get the currently selected value
237    #[must_use]
238    pub fn selected_value(&self) -> Option<&str> {
239        self.selected_value.as_deref()
240    }
241
242    /// Set the selected value programmatically
243    pub fn set_selected(&mut self, value: Option<String>) {
244        self.selected_value = value;
245    }
246
247    // ========================================================================
248    // Main show method
249    // ========================================================================
250
251    /// Show the Select component
252    pub fn show(&mut self, ui: &mut Ui) -> SelectResponse {
253        let theme = ui.ctx().armas_theme();
254        let width = self.width.unwrap_or(200.0);
255        let mut changed = false;
256        let mut new_value = None;
257
258        self.load_state(ui);
259
260        ui.vertical(|ui| {
261            ui.spacing_mut().item_spacing.y = theme.spacing.xs;
262
263            self.show_label(ui, &theme);
264            let (button_rect, response) = self.show_trigger(ui, &theme, width);
265
266            if response.clicked() {
267                self.toggle_dropdown();
268            }
269
270            if self.is_open {
271                let dropdown_response = self.show_dropdown(ui, &theme, button_rect, width);
272                if let Some(value) = dropdown_response.selected_value {
273                    self.selected_value = Some(value.clone());
274                    new_value = Some(value);
275                    changed = true;
276                    self.is_open = false;
277                }
278                if dropdown_response.should_close {
279                    self.is_open = false;
280                }
281            }
282
283            self.save_state(ui);
284
285            SelectResponse {
286                response,
287                changed,
288                selected_value: new_value,
289                is_open: self.is_open,
290            }
291        })
292        .inner
293    }
294
295    // ========================================================================
296    // State persistence
297    // ========================================================================
298
299    fn load_state(&mut self, ui: &Ui) {
300        let Some(id) = self.id else { return };
301        let state_id = id.with("select_state");
302        let stored: Option<(Option<String>, bool, String, Option<usize>)> =
303            ui.ctx().data_mut(|d| d.get_temp(state_id));
304
305        if let Some((selected_value, is_open, search_text, highlighted_index)) = stored {
306            self.selected_value = selected_value;
307            self.is_open = is_open;
308            self.search_text = search_text;
309            self.highlighted_index = highlighted_index;
310            if !self.search_text.is_empty() {
311                self.update_filter();
312            }
313        }
314    }
315
316    fn save_state(&self, ui: &Ui) {
317        let Some(id) = self.id else { return };
318        let state_id = id.with("select_state");
319        ui.ctx().data_mut(|d| {
320            d.insert_temp(
321                state_id,
322                (
323                    self.selected_value.clone(),
324                    self.is_open,
325                    self.search_text.clone(),
326                    self.highlighted_index,
327                ),
328            );
329        });
330    }
331
332    // ========================================================================
333    // Trigger button
334    // ========================================================================
335
336    fn show_label(&self, ui: &mut Ui, theme: &Theme) {
337        if let Some(label) = &self.label {
338            ui.label(
339                egui::RichText::new(label)
340                    .size(theme.typography.base)
341                    .color(theme.foreground()),
342            );
343        }
344    }
345
346    fn show_trigger(&self, ui: &mut Ui, theme: &Theme, width: f32) -> (Rect, Response) {
347        let height = self.custom_height.unwrap_or(TRIGGER_HEIGHT);
348        let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::click());
349
350        if ui.is_rect_visible(rect) {
351            self.paint_trigger(ui.painter(), rect, &response, theme, height);
352        }
353
354        (rect, response)
355    }
356
357    fn paint_trigger(
358        &self,
359        painter: &Painter,
360        rect: Rect,
361        response: &Response,
362        theme: &Theme,
363        height: f32,
364    ) {
365        let hovered = response.hovered();
366        let is_focused = self.is_open;
367        let cr = self.custom_corner_radius.unwrap_or(CORNER_RADIUS);
368        let corner_radius = CornerRadius::same(cr);
369
370        // Background
371        let bg_color = if hovered && !is_focused {
372            let input = theme.input();
373            Color32::from_rgba_unmultiplied(input.r(), input.g(), input.b(), 128)
374        } else {
375            Color32::TRANSPARENT
376        };
377        painter.rect_filled(rect, corner_radius, bg_color);
378
379        // Border
380        let border_color = if is_focused {
381            theme.ring()
382        } else {
383            theme.input()
384        };
385        painter.rect_stroke(
386            rect,
387            corner_radius,
388            Stroke::new(1.0, border_color),
389            egui::StrokeKind::Inside,
390        );
391
392        // Focus ring
393        if is_focused {
394            let ring_color = {
395                let r = theme.ring();
396                Color32::from_rgba_unmultiplied(r.r(), r.g(), r.b(), 128)
397            };
398            painter.rect_stroke(
399                rect.expand(2.0),
400                CornerRadius::same(cr + 2),
401                Stroke::new(2.0, ring_color),
402                egui::StrokeKind::Outside,
403            );
404        }
405
406        // Scale font and padding for small heights
407        let font_size = self.custom_font_size.unwrap_or_else(|| {
408            if height < 30.0 {
409                (height * 0.55).max(8.0)
410            } else {
411                theme.typography.base
412            }
413        });
414        let padding_x = self.custom_padding_x.unwrap_or_else(|| {
415            if height < 30.0 {
416                (height * 0.3).max(4.0)
417            } else {
418                12.0
419            }
420        });
421
422        // Display text
423        let display_text = self.get_display_text();
424        let text_color = if self.selected_value.is_some() {
425            theme.foreground()
426        } else {
427            theme.muted_foreground()
428        };
429        painter.text(
430            rect.left_center() + vec2(padding_x, 0.0),
431            egui::Align2::LEFT_CENTER,
432            display_text,
433            egui::FontId::proportional(font_size),
434            text_color,
435        );
436
437        // Solid triangle indicator
438        let tri_size = if height < 30.0 {
439            (height * 0.15).max(2.5)
440        } else {
441            4.0
442        };
443        let center = rect.right_center() - vec2(padding_x + tri_size, 0.0);
444        let triangle = if self.is_open {
445            // Pointing up
446            vec![
447                egui::pos2(center.x, center.y - tri_size),
448                egui::pos2(center.x - tri_size, center.y + tri_size * 0.6),
449                egui::pos2(center.x + tri_size, center.y + tri_size * 0.6),
450            ]
451        } else {
452            // Pointing down
453            vec![
454                egui::pos2(center.x, center.y + tri_size),
455                egui::pos2(center.x - tri_size, center.y - tri_size * 0.6),
456                egui::pos2(center.x + tri_size, center.y - tri_size * 0.6),
457            ]
458        };
459        painter.add(egui::Shape::convex_polygon(
460            triangle,
461            theme.muted_foreground(),
462            Stroke::NONE,
463        ));
464    }
465
466    fn get_display_text(&self) -> &str {
467        if let Some(selected) = &self.selected_value {
468            self.options
469                .iter()
470                .find(|opt| opt.value == *selected)
471                .map_or(&self.placeholder, |opt| opt.label.as_str())
472        } else {
473            &self.placeholder
474        }
475    }
476
477    fn toggle_dropdown(&mut self) {
478        self.is_open = !self.is_open;
479        if self.is_open {
480            self.search_text.clear();
481            self.update_filter();
482            self.highlighted_index = self.filtered_indices.first().copied();
483        }
484    }
485
486    // ========================================================================
487    // Dropdown
488    // ========================================================================
489
490    fn show_dropdown(
491        &mut self,
492        ui: &mut Ui,
493        theme: &Theme,
494        button_rect: Rect,
495        width: f32,
496    ) -> DropdownResponse {
497        let mut selected_value = None;
498        let mut should_close = false;
499
500        let dropdown_id = ui.id().with("dropdown");
501        let area_response = egui::Area::new(dropdown_id)
502            .fixed_pos(button_rect.left_bottom() + vec2(0.0, 4.0))
503            .order(egui::Order::Foreground)
504            .show(ui.ctx(), |ui| {
505                egui::Frame::new()
506                    .fill(theme.popover())
507                    .stroke(Stroke::new(1.0, theme.border()))
508                    .corner_radius(CornerRadius::same(
509                        self.custom_corner_radius.unwrap_or(CORNER_RADIUS),
510                    ))
511                    .inner_margin(4.0)
512                    .shadow(egui::epaint::Shadow {
513                        offset: [0, 4],
514                        blur: 8,
515                        spread: 0,
516                        color: Color32::from_black_alpha(60),
517                    })
518                    .show(ui, |ui| {
519                        ui.set_width(width - 8.0);
520
521                        if self.searchable {
522                            should_close |= self.show_search_box(ui, width);
523                            self.show_separator(ui, theme, width);
524                        }
525
526                        self.show_options_list(ui, theme, width, &mut selected_value);
527                    });
528            });
529
530        should_close |= self.handle_keyboard_input(ui, &mut selected_value);
531        should_close |=
532            self.should_close_on_click_outside(ui, &area_response.response, button_rect);
533
534        DropdownResponse {
535            selected_value,
536            should_close,
537        }
538    }
539
540    fn show_search_box(&mut self, ui: &mut Ui, width: f32) -> bool {
541        let search_response = ui.add(
542            TextEdit::singleline(&mut self.search_text)
543                .hint_text("Search...")
544                .desired_width(width - 16.0)
545                .frame(true),
546        );
547
548        if search_response.changed() {
549            self.update_filter();
550            self.highlighted_index = self.filtered_indices.first().copied();
551        }
552
553        ui.input(|i| i.key_pressed(Key::Escape))
554    }
555
556    fn show_separator(&self, ui: &mut Ui, theme: &Theme, width: f32) {
557        ui.add_space(4.0);
558        let sep_rect = ui.available_rect_before_wrap();
559        let sep_rect = Rect::from_min_size(sep_rect.min, vec2(width - 16.0, 1.0));
560        ui.painter().rect_filled(sep_rect, 0.0, theme.border());
561        ui.allocate_space(vec2(width - 16.0, 1.0));
562        ui.add_space(4.0);
563    }
564
565    fn show_options_list(
566        &mut self,
567        ui: &mut Ui,
568        theme: &Theme,
569        width: f32,
570        selected_value: &mut Option<String>,
571    ) {
572        egui::ScrollArea::vertical()
573            .max_height(self.max_height)
574            .show(ui, |ui| {
575                if self.filtered_indices.is_empty() {
576                    ui.label(
577                        egui::RichText::new("No results found.")
578                            .color(theme.muted_foreground())
579                            .size(theme.typography.base),
580                    );
581                    return;
582                }
583
584                let indices = self.filtered_indices.clone();
585                for option_idx in indices {
586                    let option = self.options[option_idx].clone();
587
588                    if option.disabled {
589                        self.show_disabled_option(ui, &option, theme, width);
590                    } else if let Some(value) =
591                        self.show_option(ui, &option, option_idx, theme, width)
592                    {
593                        *selected_value = Some(value);
594                    }
595                }
596            });
597    }
598
599    fn item_height(&self) -> f32 {
600        self.custom_height.unwrap_or(ITEM_HEIGHT)
601    }
602
603    fn item_font_size(&self) -> f32 {
604        self.custom_font_size.unwrap_or_else(|| {
605            let h = self.item_height();
606            if h < 30.0 {
607                (h * 0.55).max(8.0)
608            } else {
609                14.0
610            }
611        })
612    }
613
614    fn show_disabled_option(&self, ui: &mut Ui, option: &SelectOption, theme: &Theme, width: f32) {
615        let (rect, _) =
616            ui.allocate_exact_size(vec2(width - 16.0, self.item_height()), Sense::hover());
617
618        if !ui.is_rect_visible(rect) {
619            return;
620        }
621
622        let content_rect = rect.shrink2(vec2(PADDING, 0.0));
623        let color = theme.muted_foreground().linear_multiply(0.5);
624        let mut label_x = 0.0;
625
626        let font_size = self.item_font_size();
627        if let Some(icon) = &option.icon {
628            ui.painter().text(
629                content_rect.left_center(),
630                egui::Align2::LEFT_CENTER,
631                icon,
632                egui::FontId::proportional(font_size),
633                color,
634            );
635            label_x = ICON_WIDTH;
636        }
637
638        ui.painter().text(
639            content_rect.left_center() + vec2(label_x, 0.0),
640            egui::Align2::LEFT_CENTER,
641            &option.label,
642            egui::FontId::proportional(font_size),
643            color,
644        );
645    }
646
647    fn show_option(
648        &mut self,
649        ui: &mut Ui,
650        option: &SelectOption,
651        option_idx: usize,
652        theme: &Theme,
653        width: f32,
654    ) -> Option<String> {
655        let is_highlighted = self.highlighted_index == Some(option_idx);
656        let base_height = self.item_height();
657        let height = if option.description.is_some() {
658            base_height + (ITEM_HEIGHT_WITH_DESC - ITEM_HEIGHT)
659        } else {
660            base_height
661        };
662
663        let (rect, response) = ui.allocate_exact_size(vec2(width - 16.0, height), Sense::click());
664
665        if !ui.is_rect_visible(rect) {
666            return None;
667        }
668
669        let is_active = is_highlighted || response.hovered();
670
671        // Background
672        if is_active {
673            ui.painter()
674                .rect_filled(rect, CornerRadius::same(CORNER_RADIUS_SM), theme.accent());
675        }
676
677        // Content
678        let text_color = if is_active {
679            theme.accent_foreground()
680        } else {
681            theme.popover_foreground()
682        };
683        self.paint_option_content(ui.painter(), rect, option, text_color, theme);
684
685        // Update highlight on hover
686        if response.hovered() {
687            self.highlighted_index = Some(option_idx);
688        }
689
690        if response.clicked() {
691            Some(option.value.clone())
692        } else {
693            None
694        }
695    }
696
697    fn paint_option_content(
698        &self,
699        painter: &Painter,
700        rect: Rect,
701        option: &SelectOption,
702        text_color: Color32,
703        theme: &Theme,
704    ) {
705        let font_size = self.item_font_size();
706        let content_rect = rect.shrink2(vec2(PADDING, 0.0));
707        // Icon
708        if let Some(icon) = &option.icon {
709            painter.text(
710                content_rect.left_center(),
711                egui::Align2::LEFT_CENTER,
712                icon,
713                egui::FontId::proportional(font_size),
714                text_color,
715            );
716        }
717        let label_x = if option.icon.is_some() {
718            ICON_WIDTH
719        } else {
720            0.0
721        };
722
723        // Label and description
724        if let Some(description) = &option.description {
725            let label_pos = content_rect.left_top() + vec2(label_x, 6.0);
726            painter.text(
727                label_pos,
728                egui::Align2::LEFT_TOP,
729                &option.label,
730                egui::FontId::proportional(font_size),
731                text_color,
732            );
733            painter.text(
734                label_pos + vec2(0.0, font_size + 4.0),
735                egui::Align2::LEFT_TOP,
736                description,
737                egui::FontId::proportional((font_size - 2.0).max(8.0)),
738                theme.muted_foreground(),
739            );
740        } else {
741            painter.text(
742                content_rect.left_center() + vec2(label_x, 0.0),
743                egui::Align2::LEFT_CENTER,
744                &option.label,
745                egui::FontId::proportional(font_size),
746                text_color,
747            );
748        }
749    }
750
751    // ========================================================================
752    // Keyboard & input handling
753    // ========================================================================
754
755    fn handle_keyboard_input(&mut self, ui: &Ui, selected_value: &mut Option<String>) -> bool {
756        let mut should_close = false;
757
758        ui.input(|i| {
759            if i.key_pressed(Key::ArrowDown) {
760                self.move_highlight(1);
761            }
762            if i.key_pressed(Key::ArrowUp) {
763                self.move_highlight(-1);
764            }
765            if i.key_pressed(Key::Enter) {
766                if let Some(idx) = self.highlighted_index {
767                    let option = &self.options[idx];
768                    if !option.disabled {
769                        *selected_value = Some(option.value.clone());
770                    }
771                }
772            }
773            if i.key_pressed(Key::Escape) {
774                should_close = true;
775            }
776        });
777
778        should_close
779    }
780
781    #[allow(clippy::cast_possible_wrap)]
782    fn move_highlight(&mut self, delta: i32) {
783        let Some(current) = self.highlighted_index else {
784            self.highlighted_index = self.filtered_indices.first().copied();
785            return;
786        };
787
788        let Some(pos) = self.filtered_indices.iter().position(|&idx| idx == current) else {
789            return;
790        };
791
792        let new_pos =
793            (pos as i32 + delta).clamp(0, self.filtered_indices.len() as i32 - 1) as usize;
794        self.highlighted_index = Some(self.filtered_indices[new_pos]);
795    }
796
797    fn should_close_on_click_outside(
798        &self,
799        ui: &Ui,
800        area_response: &Response,
801        button_rect: Rect,
802    ) -> bool {
803        let clicked = ui.input(|i| i.pointer.any_click());
804        let pointer_pos = ui.input(|i| i.pointer.interact_pos()).unwrap_or_default();
805
806        clicked && !area_response.rect.contains(pointer_pos) && !button_rect.contains(pointer_pos)
807    }
808
809    // ========================================================================
810    // Filtering
811    // ========================================================================
812
813    fn update_filter(&mut self) {
814        if self.search_text.is_empty() {
815            self.filtered_indices = (0..self.options.len()).collect();
816        } else {
817            let search_lower = self.search_text.to_lowercase();
818            self.filtered_indices = self
819                .options
820                .iter()
821                .enumerate()
822                .filter(|(_, opt)| {
823                    opt.label.to_lowercase().contains(&search_lower)
824                        || opt.value.to_lowercase().contains(&search_lower)
825                        || opt
826                            .description
827                            .as_ref()
828                            .is_some_and(|d| d.to_lowercase().contains(&search_lower))
829                })
830                .map(|(idx, _)| idx)
831                .collect();
832        }
833
834        // Reset highlight if no longer visible
835        if let Some(idx) = self.highlighted_index {
836            if !self.filtered_indices.contains(&idx) {
837                self.highlighted_index = self.filtered_indices.first().copied();
838            }
839        }
840    }
841}
842
843// ============================================================================
844// Response types
845// ============================================================================
846
847/// Response from showing a Select component
848pub struct SelectResponse {
849    /// The egui Response for the trigger button
850    pub response: Response,
851    /// Whether the selected value changed this frame
852    pub changed: bool,
853    /// The newly selected value, if changed
854    pub selected_value: Option<String>,
855    /// Whether the dropdown is currently open
856    pub is_open: bool,
857}
858
859/// Internal response for dropdown interactions
860struct DropdownResponse {
861    selected_value: Option<String>,
862    should_close: bool,
863}
864
865// ============================================================================
866// Builder (internal helper)
867// ============================================================================
868
869/// Internal builder for Select options (prefer using `Select::new()` directly)
870#[doc(hidden)]
871pub struct SelectBuilder {
872    options: Vec<SelectOption>,
873}
874
875impl SelectBuilder {
876    /// Add an option to the builder
877    pub fn option(&mut self, value: &str, label: &str) -> SelectOptionBuilder<'_> {
878        self.options.push(SelectOption::new(value, label));
879        let idx = self.options.len() - 1;
880        SelectOptionBuilder {
881            options: &mut self.options,
882            option_index: idx,
883        }
884    }
885}
886
887/// Internal builder for configuring a SelectOption
888#[doc(hidden)]
889pub struct SelectOptionBuilder<'a> {
890    options: &'a mut Vec<SelectOption>,
891    option_index: usize,
892}
893
894impl SelectOptionBuilder<'_> {
895    /// Set an icon for this option
896    #[must_use]
897    pub fn icon(self, icon: &str) -> Self {
898        if let Some(opt) = self.options.get_mut(self.option_index) {
899            opt.icon = Some(icon.to_string());
900        }
901        self
902    }
903
904    /// Set a description for this option
905    #[must_use]
906    pub fn description(self, description: &str) -> Self {
907        if let Some(opt) = self.options.get_mut(self.option_index) {
908            opt.description = Some(description.to_string());
909        }
910        self
911    }
912
913    /// Set whether this option is disabled
914    #[must_use]
915    pub fn disabled(self, disabled: bool) -> Self {
916        if let Some(opt) = self.options.get_mut(self.option_index) {
917            opt.disabled = disabled;
918        }
919        self
920    }
921}
922
923// ============================================================================
924// Tests
925// ============================================================================
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930
931    #[test]
932    fn test_select_option_creation() {
933        let option = SelectOption::new("value1", "Label 1")
934            .icon("x")
935            .description("This is a description");
936
937        assert_eq!(option.value, "value1");
938        assert_eq!(option.label, "Label 1");
939        assert_eq!(option.icon, Some("x".to_string()));
940        assert_eq!(
941            option.description,
942            Some("This is a description".to_string())
943        );
944        assert!(!option.disabled);
945    }
946
947    #[test]
948    fn test_select_creation() {
949        let options = vec![
950            SelectOption::new("1", "Option 1"),
951            SelectOption::new("2", "Option 2"),
952        ];
953
954        let select = Select::new(options);
955        assert_eq!(select.options.len(), 2);
956        assert!(select.selected_value.is_none());
957        assert!(!select.is_open);
958    }
959
960    #[test]
961    fn test_select_filtering() {
962        let options = vec![
963            SelectOption::new("apple", "Apple"),
964            SelectOption::new("banana", "Banana"),
965            SelectOption::new("cherry", "Cherry"),
966        ];
967
968        let mut select = Select::new(options);
969        select.search_text = "app".to_string();
970        select.update_filter();
971
972        assert_eq!(select.filtered_indices.len(), 1);
973        assert_eq!(select.filtered_indices[0], 0);
974    }
975}