1use crate::ext::ArmasContextExt;
12use crate::Theme;
13use egui::{
14 vec2, Color32, CornerRadius, Key, Painter, Rect, Response, Sense, Stroke, TextEdit, Ui,
15};
16
17const 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#[derive(Clone, Debug)]
35pub struct SelectOption {
36 pub value: String,
38 pub label: String,
40 pub icon: Option<String>,
42 pub description: Option<String>,
44 pub disabled: bool,
46}
47
48impl SelectOption {
49 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 #[must_use]
62 pub fn icon(mut self, icon: impl Into<String>) -> Self {
63 self.icon = Some(icon.into());
64 self
65 }
66
67 #[must_use]
69 pub fn description(mut self, description: impl Into<String>) -> Self {
70 self.description = Some(description.into());
71 self
72 }
73
74 #[must_use]
76 pub const fn disabled(mut self, disabled: bool) -> Self {
77 self.disabled = disabled;
78 self
79 }
80}
81
82pub 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 #[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 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 #[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 #[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 #[must_use]
175 pub fn label(mut self, label: impl Into<String>) -> Self {
176 self.label = Some(label.into());
177 self
178 }
179
180 #[must_use]
182 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
183 self.placeholder = placeholder.into();
184 self
185 }
186
187 #[must_use]
189 pub const fn width(mut self, width: f32) -> Self {
190 self.width = Some(width);
191 self
192 }
193
194 #[must_use]
196 pub const fn height(mut self, height: f32) -> Self {
197 self.custom_height = Some(height);
198 self
199 }
200
201 #[must_use]
203 pub const fn max_height(mut self, height: f32) -> Self {
204 self.max_height = height;
205 self
206 }
207
208 #[must_use]
210 pub const fn searchable(mut self, searchable: bool) -> Self {
211 self.searchable = searchable;
212 self
213 }
214
215 #[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 #[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 #[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 #[must_use]
238 pub fn selected_value(&self) -> Option<&str> {
239 self.selected_value.as_deref()
240 }
241
242 pub fn set_selected(&mut self, value: Option<String>) {
244 self.selected_value = value;
245 }
246
247 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 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 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 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 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 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 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 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 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 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 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 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 if is_active {
673 ui.painter()
674 .rect_filled(rect, CornerRadius::same(CORNER_RADIUS_SM), theme.accent());
675 }
676
677 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 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 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 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 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 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 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
843pub struct SelectResponse {
849 pub response: Response,
851 pub changed: bool,
853 pub selected_value: Option<String>,
855 pub is_open: bool,
857}
858
859struct DropdownResponse {
861 selected_value: Option<String>,
862 should_close: bool,
863}
864
865#[doc(hidden)]
871pub struct SelectBuilder {
872 options: Vec<SelectOption>,
873}
874
875impl SelectBuilder {
876 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#[doc(hidden)]
889pub struct SelectOptionBuilder<'a> {
890 options: &'a mut Vec<SelectOption>,
891 option_index: usize,
892}
893
894impl SelectOptionBuilder<'_> {
895 #[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 #[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 #[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#[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}