egui_material3/
select.rs

1use crate::theme::get_global_color;
2use eframe::egui::{self, Color32, FontFamily, FontId, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2, Widget};
3
4/// Material Design select/dropdown component.
5///
6/// Select components allow users to choose one option from a list.
7/// They display the currently selected option in a text field-style input
8/// and show all options in a dropdown menu when activated.
9///
10/// # Example
11/// ```rust
12/// # egui::__run_test_ui(|ui| {
13/// let mut selected = Some(1);
14/// 
15/// ui.add(MaterialSelect::new(&mut selected)
16///     .placeholder("Choose an option")
17///     .option(0, "Option 1")
18///     .option(1, "Option 2")
19///     .option(2, "Option 3")
20///     .helper_text("Select your preferred option"));
21/// # });
22/// ```
23#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
24pub struct MaterialSelect<'a> {
25    /// Reference to the currently selected option
26    selected: &'a mut Option<usize>,
27    /// List of available options
28    options: Vec<SelectOption>,
29    /// Placeholder text when no option is selected
30    placeholder: String,
31    /// Whether the select is enabled for interaction
32    enabled: bool,
33    /// Fixed width of the select component
34    width: Option<f32>,
35    /// Error message to display below the select
36    error_text: Option<String>,
37    /// Helper text to display below the select
38    helper_text: Option<String>,
39    /// Icon to show at the start of the select field
40    leading_icon: Option<String>,
41    /// Icon to show at the end of the select field (overrides default dropdown arrow)
42    trailing_icon: Option<String>,
43    /// Whether to keep the dropdown open after selecting an option
44    keep_open_on_select: bool,
45}
46
47/// Individual option in a select component.
48pub struct SelectOption {
49    /// Unique identifier for this option
50    value: usize,
51    /// Display text for this option
52    text: String,
53}
54
55impl<'a> MaterialSelect<'a> {
56    /// Create a new select component.
57    ///
58    /// # Arguments
59    /// * `selected` - Mutable reference to the currently selected option value
60    ///
61    /// # Example
62    /// ```rust
63    /// # egui::__run_test_ui(|ui| {
64    /// let mut selection = None;
65    /// let select = MaterialSelect::new(&mut selection);
66    /// # });
67    /// ```
68    pub fn new(selected: &'a mut Option<usize>) -> Self {
69        Self {
70            selected,
71            options: Vec::new(),
72            placeholder: "Select an option".to_string(),
73            enabled: true,
74            width: None,
75            error_text: None,
76            helper_text: None,
77            leading_icon: None,
78            trailing_icon: None,
79            keep_open_on_select: false,
80        }
81    }
82
83    /// Add an option to the select component.
84    ///
85    /// # Arguments
86    /// * `value` - Unique identifier for this option
87    /// * `text` - Display text for this option
88    ///
89    /// # Example
90    /// ```rust
91    /// # egui::__run_test_ui(|ui| {
92    /// let mut selection = None;
93    /// ui.add(MaterialSelect::new(&mut selection)
94    ///     .option(1, "First Option")
95    ///     .option(2, "Second Option"));
96    /// # });
97    /// ```
98    pub fn option(mut self, value: usize, text: impl Into<String>) -> Self {
99        self.options.push(SelectOption {
100            value,
101            text: text.into(),
102        });
103        self
104    }
105
106    /// Set placeholder text shown when no option is selected.
107    ///
108    /// # Arguments
109    /// * `placeholder` - The placeholder text to display
110    ///
111    /// # Example
112    /// ```rust
113    /// # egui::__run_test_ui(|ui| {
114    /// let mut selection = None;
115    /// ui.add(MaterialSelect::new(&mut selection)
116    ///     .placeholder("Choose your option"));
117    /// # });
118    /// ```
119    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
120        self.placeholder = placeholder.into();
121        self
122    }
123
124    /// Enable or disable the select component.
125    ///
126    /// # Arguments
127    /// * `enabled` - Whether the select should be enabled (true) or disabled (false)
128    ///
129    /// # Example
130    /// ```rust
131    /// # egui::__run_test_ui(|ui| {
132    /// let mut selection = None;
133    /// ui.add(MaterialSelect::new(&mut selection)
134    ///     .enabled(false)); // Disabled select
135    /// # });
136    /// ```
137    pub fn enabled(mut self, enabled: bool) -> Self {
138        self.enabled = enabled;
139        self
140    }
141
142    /// Set a fixed width for the select component.
143    ///
144    /// # Arguments
145    /// * `width` - The width in pixels
146    ///
147    /// # Example
148    /// ```rust
149    /// # egui::__run_test_ui(|ui| {
150    /// let mut selection = None;
151    /// ui.add(MaterialSelect::new(&mut selection)
152    ///     .width(300.0)); // Fixed width of 300 pixels
153    /// # });
154    /// ```
155    pub fn width(mut self, width: f32) -> Self {
156        self.width = Some(width);
157        self
158    }
159
160    /// Set error text to display below the select component.
161    ///
162    /// # Arguments
163    /// * `text` - The error message text
164    ///
165    /// # Example
166    /// ```rust
167    /// # egui::__run_test_ui(|ui| {
168    /// let mut selection = None;
169    /// ui.add(MaterialSelect::new(&mut selection)
170    ///     .error_text("This field is required")); // Error message
171    /// # });
172    /// ```
173    pub fn error_text(mut self, text: impl Into<String>) -> Self {
174        self.error_text = Some(text.into());
175        self
176    }
177
178    /// Set helper text to display below the select component.
179    ///
180    /// # Arguments
181    /// * `text` - The helper message text
182    ///
183    /// # Example
184    /// ```rust
185    /// # egui::__run_test_ui(|ui| {
186    /// let mut selection = None;
187    /// ui.add(MaterialSelect::new(&mut selection)
188    ///     .helper_text("Select an option from the list")); // Helper text
189    /// # });
190    /// ```
191    pub fn helper_text(mut self, text: impl Into<String>) -> Self {
192        self.helper_text = Some(text.into());
193        self
194    }
195
196    /// Set an icon to display at the start of the select field.
197    ///
198    /// # Arguments
199    /// * `icon` - The icon identifier (e.g., "home", "settings")
200    ///
201    /// # Example
202    /// ```rust
203    /// # egui::__run_test_ui(|ui| {
204    /// let mut selection = None;
205    /// ui.add(MaterialSelect::new(&mut selection)
206    ///     .leading_icon("settings")); // Gear icon on the left
207    /// # });
208    /// ```
209    pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
210        self.leading_icon = Some(icon.into());
211        self
212    }
213
214    /// Set an icon to display at the end of the select field (overrides default dropdown arrow).
215    ///
216    /// # Arguments
217    /// * `icon` - The icon identifier (e.g., "check", "close")
218    ///
219    /// # Example
220    /// ```rust
221    /// # egui::__run_test_ui(|ui| {
222    /// let mut selection = None;
223    /// ui.add(MaterialSelect::new(&mut selection)
224    ///     .trailing_icon("check")); // Check icon on the right
225    /// # });
226    /// ```
227    pub fn trailing_icon(mut self, icon: impl Into<String>) -> Self {
228        self.trailing_icon = Some(icon.into());
229        self
230    }
231
232    /// Set whether to keep the dropdown open after selecting an option.
233    ///
234    /// # Arguments
235    /// * `keep_open` - If true, the dropdown remains open after selection;
236    ///                 if false, it closes (default behavior)
237    ///
238    /// # Example
239    /// ```rust
240    /// # egui::__run_test_ui(|ui| {
241    /// let mut selection = None;
242    /// ui.add(MaterialSelect::new(&mut selection)
243    ///     .keep_open_on_select(true)); // Dropdown stays open after selection
244    /// # });
245    /// ```
246    pub fn keep_open_on_select(mut self, keep_open: bool) -> Self {
247        self.keep_open_on_select = keep_open;
248        self
249    }
250}
251
252impl<'a> Widget for MaterialSelect<'a> {
253    fn ui(self, ui: &mut Ui) -> Response {
254        let width = self.width.unwrap_or(200.0);
255        let height = 56.0;
256        let desired_size = Vec2::new(width, height);
257
258        let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click());
259
260        // Use persistent state for dropdown open/close with global coordination
261        let select_id = egui::Id::new(("select_widget", rect.min.x as i32, rect.min.y as i32, self.placeholder.clone()));
262        let mut open = ui.memory(|mem| mem.data.get_temp::<bool>(select_id).unwrap_or(false));
263
264        // Global state to close other select menus
265        let global_open_select_id = egui::Id::new("global_open_select");
266        let current_open_select = ui.memory(|mem| mem.data.get_temp::<egui::Id>(global_open_select_id));
267
268        if response.clicked() && self.enabled {
269            if open {
270                // Close this select
271                open = false;
272                ui.memory_mut(|mem| mem.data.remove::<egui::Id>(global_open_select_id));
273            } else {
274                // Close any other open select and open this one
275                if let Some(other_id) = current_open_select {
276                    if other_id != select_id {
277                        ui.memory_mut(|mem| mem.data.insert_temp(other_id, false));
278                    }
279                }
280                open = true;
281                ui.memory_mut(|mem| mem.data.insert_temp(global_open_select_id, select_id));
282            }
283            ui.memory_mut(|mem| mem.data.insert_temp(select_id, open));
284        }
285
286        // Material Design colors
287        let primary_color = get_global_color("primary");
288        let surface = get_global_color("surface");
289        let on_surface = get_global_color("onSurface");
290        let on_surface_variant = get_global_color("onSurfaceVariant");
291        let outline = get_global_color("outline");
292
293        let (bg_color, border_color, text_color) = if !self.enabled {
294            (
295                get_global_color("surfaceVariant").linear_multiply(0.38),
296                get_global_color("outline").linear_multiply(0.38),
297                get_global_color("onSurface").linear_multiply(0.38),
298            )
299        } else if response.hovered() || open {
300            (surface, primary_color, on_surface)
301        } else {
302            (surface, outline, on_surface_variant)
303        };
304
305        // Draw select field background
306        ui.painter().rect_filled(
307            rect,
308            4.0,
309            bg_color,
310        );
311
312        // Draw border
313        ui.painter().rect_stroke(
314            rect,
315            4.0,
316            Stroke::new(1.0, border_color),
317            egui::epaint::StrokeKind::Outside,
318        );
319
320        // Draw selected text or placeholder
321        let display_text = if let Some(selected_value) = *self.selected {
322            self.options.iter()
323                .find(|option| option.value == selected_value)
324                .map(|option| option.text.as_str())
325                .unwrap_or(&self.placeholder)
326        } else {
327            &self.placeholder
328        };
329
330        // Use consistent font styling for select field
331        let select_font = FontId::new(16.0, FontFamily::Proportional);
332        let text_pos = Pos2::new(rect.min.x + 16.0, rect.center().y);
333        ui.painter().text(
334            text_pos,
335            egui::Align2::LEFT_CENTER,
336            display_text,
337            select_font.clone(),
338            text_color,
339        );
340
341        // Draw dropdown arrow
342        let arrow_center = Pos2::new(rect.max.x - 24.0, rect.center().y);
343        let arrow_size = 8.0;
344        
345        if open {
346            // Up arrow
347            ui.painter().line_segment([
348                Pos2::new(arrow_center.x - arrow_size / 2.0, arrow_center.y + arrow_size / 4.0),
349                Pos2::new(arrow_center.x, arrow_center.y - arrow_size / 4.0),
350            ], Stroke::new(2.0, text_color));
351            ui.painter().line_segment([
352                Pos2::new(arrow_center.x, arrow_center.y - arrow_size / 4.0),
353                Pos2::new(arrow_center.x + arrow_size / 2.0, arrow_center.y + arrow_size / 4.0),
354            ], Stroke::new(2.0, text_color));
355        } else {
356            // Down arrow
357            ui.painter().line_segment([
358                Pos2::new(arrow_center.x - arrow_size / 2.0, arrow_center.y - arrow_size / 4.0),
359                Pos2::new(arrow_center.x, arrow_center.y + arrow_size / 4.0),
360            ], Stroke::new(2.0, text_color));
361            ui.painter().line_segment([
362                Pos2::new(arrow_center.x, arrow_center.y + arrow_size / 4.0),
363                Pos2::new(arrow_center.x + arrow_size / 2.0, arrow_center.y - arrow_size / 4.0),
364            ], Stroke::new(2.0, text_color));
365        }
366
367        // Show dropdown if open
368        if open {
369            // Calculate available space below and above
370            let available_space_below = ui.max_rect().max.y - rect.max.y - 4.0;
371            let available_space_above = rect.min.y - ui.max_rect().min.y - 4.0;
372            
373            let item_height = 48.0;
374            let dropdown_padding = 16.0;
375            let max_items_below = ((available_space_below - dropdown_padding) / item_height).floor() as usize;
376            let max_items_above = ((available_space_above - dropdown_padding) / item_height).floor() as usize;
377            
378            // Determine dropdown position and size
379            let (dropdown_y, visible_items, scroll_needed) = if max_items_below >= self.options.len() {
380                // Fit below
381                (rect.max.y + 4.0, self.options.len(), false)
382            } else if max_items_above >= self.options.len() {
383                // Fit above
384                let dropdown_height = self.options.len() as f32 * item_height + dropdown_padding;
385                (rect.min.y - 4.0 - dropdown_height, self.options.len(), false)
386            } else if max_items_below >= max_items_above {
387                // Partial fit below with scroll
388                (rect.max.y + 4.0, max_items_below.max(3), true)
389            } else {
390                // Partial fit above with scroll
391                let visible_items = max_items_above.max(3);
392                let dropdown_height = visible_items as f32 * item_height + dropdown_padding;
393                (rect.min.y - 4.0 - dropdown_height, visible_items, true)
394            };
395
396            let dropdown_height = visible_items as f32 * item_height + dropdown_padding;
397            let dropdown_rect = Rect::from_min_size(
398                Pos2::new(rect.min.x, dropdown_y),
399                Vec2::new(width, dropdown_height),
400            );
401
402            // Use page background color as specified
403            let dropdown_bg_color = ui.visuals().window_fill;
404
405            // Draw dropdown background with proper elevation
406            ui.painter().rect_filled(
407                dropdown_rect,
408                8.0,
409                dropdown_bg_color,
410            );
411
412            // Draw dropdown border with elevation shadow
413            ui.painter().rect_stroke(
414                dropdown_rect,
415                8.0,
416                Stroke::new(1.0, outline),
417                egui::epaint::StrokeKind::Outside,
418            );
419
420            // Draw subtle elevation shadow
421            let shadow_color = Color32::from_rgba_premultiplied(0, 0, 0, 20);
422            ui.painter().rect_filled(
423                dropdown_rect.translate(Vec2::new(0.0, 2.0)),
424                8.0,
425                shadow_color,
426            );
427
428            // Render options with scrolling support and edge attachment
429            if scroll_needed && visible_items < self.options.len() {
430                // Use scroll area for overflow with edge attachment
431                let scroll_area_rect = Rect::from_min_size(
432                    Pos2::new(dropdown_rect.min.x + 8.0, dropdown_rect.min.y + 8.0),
433                    Vec2::new(width - 16.0, dropdown_height - 16.0),
434                );
435                
436                ui.scope_builder(egui::UiBuilder::new().max_rect(scroll_area_rect), |ui| {
437                    egui::ScrollArea::vertical()
438                        .max_height(dropdown_height - 16.0)
439                        .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
440                        .auto_shrink([false; 2])
441                        .show(ui, |ui| {
442                            for option in &self.options {
443                                // Create custom option layout with proper text styling
444                                let option_height = 48.0;
445                                let (option_rect, option_response) = ui.allocate_exact_size(
446                                    Vec2::new(ui.available_width(), option_height), 
447                                    Sense::click()
448                                );
449
450                                // Match select field styling
451                                let is_selected = *self.selected == Some(option.value);
452                                let option_bg_color = if is_selected {
453                                    Color32::from_rgba_premultiplied(
454                                        on_surface.r(), on_surface.g(), on_surface.b(), 30
455                                    )
456                                } else if option_response.hovered() {
457                                    Color32::from_rgba_premultiplied(
458                                        on_surface.r(), on_surface.g(), on_surface.b(), 20
459                                    )
460                                } else {
461                                    Color32::TRANSPARENT
462                                };
463
464                                if option_bg_color != Color32::TRANSPARENT {
465                                    ui.painter().rect_filled(option_rect, 4.0, option_bg_color);
466                                }
467
468                                // Use same font as select field with text wrapping
469                                let text_pos = Pos2::new(option_rect.min.x + 16.0, option_rect.center().y);
470                                let text_color = if is_selected { 
471                                    get_global_color("primary") 
472                                } else { 
473                                    on_surface 
474                                };
475                                
476                                // Handle text wrapping for long content
477                                let available_width = option_rect.width() - 32.0; // Account for padding
478                                let galley = ui.fonts(|f| f.layout_job(egui::text::LayoutJob {
479                                    text: option.text.clone(),
480                                    sections: vec![egui::text::LayoutSection {
481                                        leading_space: 0.0,
482                                        byte_range: 0..option.text.len(),
483                                        format: egui::TextFormat {
484                                            font_id: select_font.clone(),
485                                            color: text_color,
486                                            ..Default::default()
487                                        },
488                                    }],
489                                    wrap: egui::text::TextWrapping {
490                                        max_width: available_width,
491                                        ..Default::default()
492                                    },
493                                    break_on_newline: true,
494                                    halign: egui::Align::LEFT,
495                                    justify: false,
496                                    first_row_min_height: 0.0,
497                                    round_output_to_gui: true,
498                                }));
499                                
500                                ui.painter().galley(text_pos, galley, text_color);
501
502                                if option_response.clicked() {
503                                    *self.selected = Some(option.value);
504                                    if !self.keep_open_on_select {
505                                        open = false;
506                                        ui.memory_mut(|mem| {
507                                            mem.data.insert_temp(select_id, open);
508                                            mem.data.remove::<egui::Id>(global_open_select_id);
509                                        });
510                                    }
511                                    response.mark_changed();
512                                }
513                            }
514                        });
515                });
516            } else {
517                // Draw options normally without scrolling
518                let mut current_y = dropdown_rect.min.y + 8.0;
519                let items_to_show = visible_items.min(self.options.len());
520                
521                for option in self.options.iter().take(items_to_show) {
522                    let option_rect = Rect::from_min_size(
523                        Pos2::new(dropdown_rect.min.x + 8.0, current_y),
524                        Vec2::new(width - 16.0, item_height),
525                    );
526
527                    let option_response = ui.interact(
528                        option_rect,
529                        egui::Id::new(("select_option", option.value, option.text.clone())),
530                        Sense::click(),
531                    );
532
533                    // Highlight selected option
534                    let is_selected = *self.selected == Some(option.value);
535                    let option_bg_color = if is_selected {
536                        Color32::from_rgba_premultiplied(
537                            on_surface.r(), on_surface.g(), on_surface.b(), 30
538                        )
539                    } else if option_response.hovered() {
540                        Color32::from_rgba_premultiplied(
541                            on_surface.r(), on_surface.g(), on_surface.b(), 20
542                        )
543                    } else {
544                        Color32::TRANSPARENT
545                    };
546
547                    if option_bg_color != Color32::TRANSPARENT {
548                        ui.painter().rect_filled(option_rect, 4.0, option_bg_color);
549                    }
550
551                    if option_response.clicked() {
552                        *self.selected = Some(option.value);
553                        if !self.keep_open_on_select {
554                            open = false;
555                            ui.memory_mut(|mem| {
556                                mem.data.insert_temp(select_id, open);
557                                mem.data.remove::<egui::Id>(global_open_select_id);
558                            });
559                        }
560                        response.mark_changed();
561                    }
562
563                    let text_pos = Pos2::new(option_rect.min.x + 16.0, option_rect.center().y);
564                    let text_color = if is_selected { 
565                        get_global_color("primary") 
566                    } else { 
567                        on_surface 
568                    };
569                    
570                    // Handle text wrapping for long content
571                    let available_width = option_rect.width() - 32.0; // Account for padding
572                    let galley = ui.fonts(|f| f.layout_job(egui::text::LayoutJob {
573                        text: option.text.clone(),
574                        sections: vec![egui::text::LayoutSection {
575                            leading_space: 0.0,
576                            byte_range: 0..option.text.len(),
577                            format: egui::TextFormat {
578                                font_id: select_font.clone(),
579                                color: text_color,
580                                ..Default::default()
581                            },
582                        }],
583                        wrap: egui::text::TextWrapping {
584                            max_width: available_width,
585                            ..Default::default()
586                        },
587                        break_on_newline: true,
588                        halign: egui::Align::LEFT,
589                        justify: false,
590                        first_row_min_height: 0.0,
591                        round_output_to_gui: true,
592                    }));
593                    
594                    ui.painter().galley(text_pos, galley, text_color);
595
596                    current_y += item_height;
597                }
598            }
599        }
600
601        response
602    }
603}
604
605/// Convenience function to create a select component.
606///
607/// Shorthand for `MaterialSelect::new()`.
608///
609/// # Arguments
610/// * `selected` - Mutable reference to the currently selected option value
611///
612/// # Example
613/// ```rust
614/// # egui::__run_test_ui(|ui| {
615/// let mut selection = Some(1);
616/// ui.add(select(&mut selection)
617///     .option(0, "Option 1")
618///     .option(1, "Option 2"));
619/// # });
620/// ```
621pub fn select<'a>(selected: &'a mut Option<usize>) -> MaterialSelect<'a> {
622    MaterialSelect::new(selected)
623}