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}