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 avail = ui.available_width();
255        let width = self
256            .width
257            .unwrap_or_else(|| if avail.is_finite() { avail } else { 200.0 });
258        let mut changed = false;
259        let mut new_value = None;
260
261        self.load_state(ui);
262
263        ui.vertical(|ui| {
264            ui.spacing_mut().item_spacing.y = theme.spacing.xs;
265
266            self.show_label(ui, &theme);
267            let (button_rect, response) = self.show_trigger(ui, &theme, width);
268
269            if response.clicked() {
270                self.toggle_dropdown();
271            }
272
273            if self.is_open {
274                let dropdown_response = self.show_dropdown(ui, &theme, button_rect, width);
275                if let Some(value) = dropdown_response.selected_value {
276                    self.selected_value = Some(value.clone());
277                    new_value = Some(value);
278                    changed = true;
279                    self.is_open = false;
280                }
281                if dropdown_response.should_close {
282                    self.is_open = false;
283                }
284            }
285
286            self.save_state(ui);
287
288            SelectResponse {
289                response,
290                changed,
291                selected_value: new_value,
292                is_open: self.is_open,
293            }
294        })
295        .inner
296    }
297
298    // ========================================================================
299    // State persistence
300    // ========================================================================
301
302    fn load_state(&mut self, ui: &Ui) {
303        let Some(id) = self.id else { return };
304        let state_id = id.with("select_state");
305        let stored: Option<(Option<String>, bool, String, Option<usize>)> =
306            ui.ctx().data_mut(|d| d.get_temp(state_id));
307
308        if let Some((selected_value, is_open, search_text, highlighted_index)) = stored {
309            self.selected_value = selected_value;
310            self.is_open = is_open;
311            self.search_text = search_text;
312            self.highlighted_index = highlighted_index;
313            if !self.search_text.is_empty() {
314                self.update_filter();
315            }
316        }
317    }
318
319    fn save_state(&self, ui: &Ui) {
320        let Some(id) = self.id else { return };
321        let state_id = id.with("select_state");
322        ui.ctx().data_mut(|d| {
323            d.insert_temp(
324                state_id,
325                (
326                    self.selected_value.clone(),
327                    self.is_open,
328                    self.search_text.clone(),
329                    self.highlighted_index,
330                ),
331            );
332        });
333    }
334
335    // ========================================================================
336    // Trigger button
337    // ========================================================================
338
339    fn show_label(&self, ui: &mut Ui, theme: &Theme) {
340        if let Some(label) = &self.label {
341            ui.label(
342                egui::RichText::new(label)
343                    .size(theme.typography.base)
344                    .color(theme.foreground()),
345            );
346        }
347    }
348
349    fn show_trigger(&self, ui: &mut Ui, theme: &Theme, width: f32) -> (Rect, Response) {
350        let avail_h = ui.available_height();
351        let default_h = if avail_h.is_finite() && avail_h > 0.0 && avail_h < TRIGGER_HEIGHT {
352            avail_h
353        } else {
354            TRIGGER_HEIGHT
355        };
356        let height = self.custom_height.unwrap_or(default_h);
357        let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::click());
358
359        if ui.is_rect_visible(rect) {
360            self.paint_trigger(ui.painter(), rect, &response, theme, height);
361        }
362
363        (rect, response)
364    }
365
366    fn paint_trigger(
367        &self,
368        painter: &Painter,
369        rect: Rect,
370        response: &Response,
371        theme: &Theme,
372        height: f32,
373    ) {
374        let hovered = response.hovered();
375        let is_focused = self.is_open;
376        let cr = self.custom_corner_radius.unwrap_or(CORNER_RADIUS);
377        let corner_radius = CornerRadius::same(cr);
378
379        // Background
380        let bg_color = if hovered && !is_focused {
381            let input = theme.input();
382            Color32::from_rgba_unmultiplied(input.r(), input.g(), input.b(), 128)
383        } else {
384            Color32::TRANSPARENT
385        };
386        painter.rect_filled(rect, corner_radius, bg_color);
387
388        // Border
389        let border_color = if is_focused {
390            theme.ring()
391        } else {
392            theme.input()
393        };
394        painter.rect_stroke(
395            rect,
396            corner_radius,
397            Stroke::new(1.0, border_color),
398            egui::StrokeKind::Inside,
399        );
400
401        // Focus ring
402        if is_focused {
403            let ring_color = {
404                let r = theme.ring();
405                Color32::from_rgba_unmultiplied(r.r(), r.g(), r.b(), 128)
406            };
407            painter.rect_stroke(
408                rect.expand(2.0),
409                CornerRadius::same(cr + 2),
410                Stroke::new(2.0, ring_color),
411                egui::StrokeKind::Outside,
412            );
413        }
414
415        // Scale font and padding for small heights
416        let font_size = self.custom_font_size.unwrap_or_else(|| {
417            if height < 30.0 {
418                (height * 0.55).max(8.0)
419            } else {
420                theme.typography.base
421            }
422        });
423        let padding_x = self.custom_padding_x.unwrap_or_else(|| {
424            if height < 30.0 {
425                (height * 0.3).max(4.0)
426            } else {
427                12.0
428            }
429        });
430
431        // Display text
432        let display_text = self.get_display_text();
433        let text_color = if self.selected_value.is_some() {
434            theme.foreground()
435        } else {
436            theme.muted_foreground()
437        };
438        painter.text(
439            rect.left_center() + vec2(padding_x, 0.0),
440            egui::Align2::LEFT_CENTER,
441            display_text,
442            egui::FontId::proportional(font_size),
443            text_color,
444        );
445
446        // Solid triangle indicator
447        let tri_size = if height < 30.0 {
448            (height * 0.15).max(2.5)
449        } else {
450            4.0
451        };
452        let center = rect.right_center() - vec2(padding_x + tri_size, 0.0);
453        let triangle = if self.is_open {
454            // Pointing up
455            vec![
456                egui::pos2(center.x, center.y - tri_size),
457                egui::pos2(center.x - tri_size, center.y + tri_size * 0.6),
458                egui::pos2(center.x + tri_size, center.y + tri_size * 0.6),
459            ]
460        } else {
461            // Pointing down
462            vec![
463                egui::pos2(center.x, center.y + tri_size),
464                egui::pos2(center.x - tri_size, center.y - tri_size * 0.6),
465                egui::pos2(center.x + tri_size, center.y - tri_size * 0.6),
466            ]
467        };
468        painter.add(egui::Shape::convex_polygon(
469            triangle,
470            theme.muted_foreground(),
471            Stroke::NONE,
472        ));
473    }
474
475    fn get_display_text(&self) -> &str {
476        if let Some(selected) = &self.selected_value {
477            self.options
478                .iter()
479                .find(|opt| opt.value == *selected)
480                .map_or(&self.placeholder, |opt| opt.label.as_str())
481        } else {
482            &self.placeholder
483        }
484    }
485
486    fn toggle_dropdown(&mut self) {
487        self.is_open = !self.is_open;
488        if self.is_open {
489            self.search_text.clear();
490            self.update_filter();
491            self.highlighted_index = self.filtered_indices.first().copied();
492        }
493    }
494
495    // ========================================================================
496    // Dropdown
497    // ========================================================================
498
499    fn show_dropdown(
500        &mut self,
501        ui: &mut Ui,
502        theme: &Theme,
503        button_rect: Rect,
504        width: f32,
505    ) -> DropdownResponse {
506        let mut selected_value = None;
507        let mut should_close = false;
508
509        let dropdown_id = ui.id().with("dropdown");
510        let area_response = egui::Area::new(dropdown_id)
511            .fixed_pos(button_rect.left_bottom() + vec2(0.0, 4.0))
512            .order(egui::Order::Foreground)
513            .show(ui.ctx(), |ui| {
514                egui::Frame::new()
515                    .fill(theme.popover())
516                    .stroke(Stroke::new(1.0, theme.border()))
517                    .corner_radius(CornerRadius::same(
518                        self.custom_corner_radius.unwrap_or(CORNER_RADIUS),
519                    ))
520                    .inner_margin(4.0)
521                    .shadow(egui::epaint::Shadow {
522                        offset: [0, 4],
523                        blur: 8,
524                        spread: 0,
525                        color: Color32::from_black_alpha(60),
526                    })
527                    .show(ui, |ui| {
528                        ui.set_width(width - 8.0);
529
530                        if self.searchable {
531                            should_close |= self.show_search_box(ui, width);
532                            self.show_separator(ui, theme, width);
533                        }
534
535                        self.show_options_list(ui, theme, width, &mut selected_value);
536                    });
537            });
538
539        should_close |= self.handle_keyboard_input(ui, &mut selected_value);
540        should_close |=
541            self.should_close_on_click_outside(ui, &area_response.response, button_rect);
542
543        DropdownResponse {
544            selected_value,
545            should_close,
546        }
547    }
548
549    fn show_search_box(&mut self, ui: &mut Ui, width: f32) -> bool {
550        let search_response = ui.add(
551            TextEdit::singleline(&mut self.search_text)
552                .hint_text("Search...")
553                .desired_width(width - 16.0)
554                .frame(true),
555        );
556
557        if search_response.changed() {
558            self.update_filter();
559            self.highlighted_index = self.filtered_indices.first().copied();
560        }
561
562        ui.input(|i| i.key_pressed(Key::Escape))
563    }
564
565    fn show_separator(&self, ui: &mut Ui, theme: &Theme, width: f32) {
566        ui.add_space(4.0);
567        let sep_rect = ui.available_rect_before_wrap();
568        let sep_rect = Rect::from_min_size(sep_rect.min, vec2(width - 16.0, 1.0));
569        ui.painter().rect_filled(sep_rect, 0.0, theme.border());
570        ui.allocate_space(vec2(width - 16.0, 1.0));
571        ui.add_space(4.0);
572    }
573
574    fn show_options_list(
575        &mut self,
576        ui: &mut Ui,
577        theme: &Theme,
578        width: f32,
579        selected_value: &mut Option<String>,
580    ) {
581        egui::ScrollArea::vertical()
582            .max_height(self.max_height)
583            .show(ui, |ui| {
584                if self.filtered_indices.is_empty() {
585                    ui.label(
586                        egui::RichText::new("No results found.")
587                            .color(theme.muted_foreground())
588                            .size(theme.typography.base),
589                    );
590                    return;
591                }
592
593                let indices = self.filtered_indices.clone();
594                for option_idx in indices {
595                    let option = self.options[option_idx].clone();
596
597                    if option.disabled {
598                        self.show_disabled_option(ui, &option, theme, width);
599                    } else if let Some(value) =
600                        self.show_option(ui, &option, option_idx, theme, width)
601                    {
602                        *selected_value = Some(value);
603                    }
604                }
605            });
606    }
607
608    fn item_height(&self) -> f32 {
609        self.custom_height.unwrap_or(ITEM_HEIGHT)
610    }
611
612    fn item_font_size(&self) -> f32 {
613        self.custom_font_size.unwrap_or_else(|| {
614            let h = self.item_height();
615            if h < 30.0 {
616                (h * 0.55).max(8.0)
617            } else {
618                14.0
619            }
620        })
621    }
622
623    fn show_disabled_option(&self, ui: &mut Ui, option: &SelectOption, theme: &Theme, width: f32) {
624        let (rect, _) =
625            ui.allocate_exact_size(vec2(width - 16.0, self.item_height()), Sense::hover());
626
627        if !ui.is_rect_visible(rect) {
628            return;
629        }
630
631        let content_rect = rect.shrink2(vec2(PADDING, 0.0));
632        let color = theme.muted_foreground().linear_multiply(0.5);
633        let mut label_x = 0.0;
634
635        let font_size = self.item_font_size();
636        if let Some(icon) = &option.icon {
637            ui.painter().text(
638                content_rect.left_center(),
639                egui::Align2::LEFT_CENTER,
640                icon,
641                egui::FontId::proportional(font_size),
642                color,
643            );
644            label_x = ICON_WIDTH;
645        }
646
647        ui.painter().text(
648            content_rect.left_center() + vec2(label_x, 0.0),
649            egui::Align2::LEFT_CENTER,
650            &option.label,
651            egui::FontId::proportional(font_size),
652            color,
653        );
654    }
655
656    fn show_option(
657        &mut self,
658        ui: &mut Ui,
659        option: &SelectOption,
660        option_idx: usize,
661        theme: &Theme,
662        width: f32,
663    ) -> Option<String> {
664        let is_highlighted = self.highlighted_index == Some(option_idx);
665        let base_height = self.item_height();
666        let height = if option.description.is_some() {
667            base_height + (ITEM_HEIGHT_WITH_DESC - ITEM_HEIGHT)
668        } else {
669            base_height
670        };
671
672        let (rect, response) = ui.allocate_exact_size(vec2(width - 16.0, height), Sense::click());
673
674        if !ui.is_rect_visible(rect) {
675            return None;
676        }
677
678        let is_active = is_highlighted || response.hovered();
679
680        // Background
681        if is_active {
682            ui.painter()
683                .rect_filled(rect, CornerRadius::same(CORNER_RADIUS_SM), theme.accent());
684        }
685
686        // Content
687        let text_color = if is_active {
688            theme.accent_foreground()
689        } else {
690            theme.popover_foreground()
691        };
692        self.paint_option_content(ui.painter(), rect, option, text_color, theme);
693
694        // Update highlight on hover
695        if response.hovered() {
696            self.highlighted_index = Some(option_idx);
697        }
698
699        if response.clicked() {
700            Some(option.value.clone())
701        } else {
702            None
703        }
704    }
705
706    fn paint_option_content(
707        &self,
708        painter: &Painter,
709        rect: Rect,
710        option: &SelectOption,
711        text_color: Color32,
712        theme: &Theme,
713    ) {
714        let font_size = self.item_font_size();
715        let content_rect = rect.shrink2(vec2(PADDING, 0.0));
716        // Icon
717        if let Some(icon) = &option.icon {
718            painter.text(
719                content_rect.left_center(),
720                egui::Align2::LEFT_CENTER,
721                icon,
722                egui::FontId::proportional(font_size),
723                text_color,
724            );
725        }
726        let label_x = if option.icon.is_some() {
727            ICON_WIDTH
728        } else {
729            0.0
730        };
731
732        // Label and description
733        if let Some(description) = &option.description {
734            let label_pos = content_rect.left_top() + vec2(label_x, 6.0);
735            painter.text(
736                label_pos,
737                egui::Align2::LEFT_TOP,
738                &option.label,
739                egui::FontId::proportional(font_size),
740                text_color,
741            );
742            painter.text(
743                label_pos + vec2(0.0, font_size + 4.0),
744                egui::Align2::LEFT_TOP,
745                description,
746                egui::FontId::proportional((font_size - 2.0).max(8.0)),
747                theme.muted_foreground(),
748            );
749        } else {
750            painter.text(
751                content_rect.left_center() + vec2(label_x, 0.0),
752                egui::Align2::LEFT_CENTER,
753                &option.label,
754                egui::FontId::proportional(font_size),
755                text_color,
756            );
757        }
758    }
759
760    // ========================================================================
761    // Keyboard & input handling
762    // ========================================================================
763
764    fn handle_keyboard_input(&mut self, ui: &Ui, selected_value: &mut Option<String>) -> bool {
765        let mut should_close = false;
766
767        ui.input(|i| {
768            if i.key_pressed(Key::ArrowDown) {
769                self.move_highlight(1);
770            }
771            if i.key_pressed(Key::ArrowUp) {
772                self.move_highlight(-1);
773            }
774            if i.key_pressed(Key::Enter) {
775                if let Some(idx) = self.highlighted_index {
776                    let option = &self.options[idx];
777                    if !option.disabled {
778                        *selected_value = Some(option.value.clone());
779                    }
780                }
781            }
782            if i.key_pressed(Key::Escape) {
783                should_close = true;
784            }
785        });
786
787        should_close
788    }
789
790    #[allow(clippy::cast_possible_wrap)]
791    fn move_highlight(&mut self, delta: i32) {
792        let Some(current) = self.highlighted_index else {
793            self.highlighted_index = self.filtered_indices.first().copied();
794            return;
795        };
796
797        let Some(pos) = self.filtered_indices.iter().position(|&idx| idx == current) else {
798            return;
799        };
800
801        let new_pos =
802            (pos as i32 + delta).clamp(0, self.filtered_indices.len() as i32 - 1) as usize;
803        self.highlighted_index = Some(self.filtered_indices[new_pos]);
804    }
805
806    fn should_close_on_click_outside(
807        &self,
808        ui: &Ui,
809        area_response: &Response,
810        button_rect: Rect,
811    ) -> bool {
812        let clicked = ui.input(|i| i.pointer.any_click());
813        let pointer_pos = ui.input(|i| i.pointer.interact_pos()).unwrap_or_default();
814
815        clicked && !area_response.rect.contains(pointer_pos) && !button_rect.contains(pointer_pos)
816    }
817
818    // ========================================================================
819    // Filtering
820    // ========================================================================
821
822    fn update_filter(&mut self) {
823        if self.search_text.is_empty() {
824            self.filtered_indices = (0..self.options.len()).collect();
825        } else {
826            let search_lower = self.search_text.to_lowercase();
827            self.filtered_indices = self
828                .options
829                .iter()
830                .enumerate()
831                .filter(|(_, opt)| {
832                    opt.label.to_lowercase().contains(&search_lower)
833                        || opt.value.to_lowercase().contains(&search_lower)
834                        || opt
835                            .description
836                            .as_ref()
837                            .is_some_and(|d| d.to_lowercase().contains(&search_lower))
838                })
839                .map(|(idx, _)| idx)
840                .collect();
841        }
842
843        // Reset highlight if no longer visible
844        if let Some(idx) = self.highlighted_index {
845            if !self.filtered_indices.contains(&idx) {
846                self.highlighted_index = self.filtered_indices.first().copied();
847            }
848        }
849    }
850}
851
852// ============================================================================
853// Response types
854// ============================================================================
855
856/// Response from showing a Select component
857pub struct SelectResponse {
858    /// The egui Response for the trigger button
859    pub response: Response,
860    /// Whether the selected value changed this frame
861    pub changed: bool,
862    /// The newly selected value, if changed
863    pub selected_value: Option<String>,
864    /// Whether the dropdown is currently open
865    pub is_open: bool,
866}
867
868/// Internal response for dropdown interactions
869struct DropdownResponse {
870    selected_value: Option<String>,
871    should_close: bool,
872}
873
874// ============================================================================
875// Builder (internal helper)
876// ============================================================================
877
878/// Internal builder for Select options (prefer using `Select::new()` directly)
879#[doc(hidden)]
880pub struct SelectBuilder {
881    options: Vec<SelectOption>,
882}
883
884impl SelectBuilder {
885    /// Add an option to the builder
886    pub fn option(&mut self, value: &str, label: &str) -> SelectOptionBuilder<'_> {
887        self.options.push(SelectOption::new(value, label));
888        let idx = self.options.len() - 1;
889        SelectOptionBuilder {
890            options: &mut self.options,
891            option_index: idx,
892        }
893    }
894}
895
896/// Internal builder for configuring a SelectOption
897#[doc(hidden)]
898pub struct SelectOptionBuilder<'a> {
899    options: &'a mut Vec<SelectOption>,
900    option_index: usize,
901}
902
903impl SelectOptionBuilder<'_> {
904    /// Set an icon for this option
905    #[must_use]
906    pub fn icon(self, icon: &str) -> Self {
907        if let Some(opt) = self.options.get_mut(self.option_index) {
908            opt.icon = Some(icon.to_string());
909        }
910        self
911    }
912
913    /// Set a description for this option
914    #[must_use]
915    pub fn description(self, description: &str) -> Self {
916        if let Some(opt) = self.options.get_mut(self.option_index) {
917            opt.description = Some(description.to_string());
918        }
919        self
920    }
921
922    /// Set whether this option is disabled
923    #[must_use]
924    pub fn disabled(self, disabled: bool) -> Self {
925        if let Some(opt) = self.options.get_mut(self.option_index) {
926            opt.disabled = disabled;
927        }
928        self
929    }
930}
931
932// ============================================================================
933// Tests
934// ============================================================================
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939
940    #[test]
941    fn test_select_option_creation() {
942        let option = SelectOption::new("value1", "Label 1")
943            .icon("x")
944            .description("This is a description");
945
946        assert_eq!(option.value, "value1");
947        assert_eq!(option.label, "Label 1");
948        assert_eq!(option.icon, Some("x".to_string()));
949        assert_eq!(
950            option.description,
951            Some("This is a description".to_string())
952        );
953        assert!(!option.disabled);
954    }
955
956    #[test]
957    fn test_select_creation() {
958        let options = vec![
959            SelectOption::new("1", "Option 1"),
960            SelectOption::new("2", "Option 2"),
961        ];
962
963        let select = Select::new(options);
964        assert_eq!(select.options.len(), 2);
965        assert!(select.selected_value.is_none());
966        assert!(!select.is_open);
967    }
968
969    #[test]
970    fn test_select_filtering() {
971        let options = vec![
972            SelectOption::new("apple", "Apple"),
973            SelectOption::new("banana", "Banana"),
974            SelectOption::new("cherry", "Cherry"),
975        ];
976
977        let mut select = Select::new(options);
978        select.search_text = "app".to_string();
979        select.update_filter();
980
981        assert_eq!(select.filtered_indices.len(), 1);
982        assert_eq!(select.filtered_indices[0], 0);
983    }
984}