Skip to main content

egui_components/
select.rs

1//! `Select` / `Combobox` — a dropdown that picks one option from a list.
2//!
3//! The selected index lives on the caller (`&mut Option<usize>`), mirroring
4//! the state-on-the-caller pattern used by [`Checkbox`](crate::checkbox) and
5//! [`List`](crate::list). Build it with an id salt (so multiple selects on a
6//! page don't collide), a slice of option labels, and the bound selection:
7//!
8//! ```ignore
9//! sc::Select::new("fruit", &mut self.fruit)
10//!     .options(["Apple", "Banana", "Cherry"])
11//!     .placeholder("Pick a fruit")
12//!     .show(ui);
13//! ```
14//!
15//! Add [`searchable`](Select::searchable) (or use [`Select::combobox`]) to get
16//! a filter field at the top of the dropdown.
17
18use egui::{
19    pos2, vec2, Frame, Id, Margin, Response, Sense, Stroke, Ui,
20};
21use egui_components_theme::{mix, Theme};
22
23use crate::common::Size;
24use crate::input::Input;
25use crate::list::ListItem;
26
27pub struct Select<'a> {
28    id_salt: Id,
29    selected: &'a mut Option<usize>,
30    options: Vec<String>,
31    placeholder: String,
32    width: Option<f32>,
33    max_dropdown_height: f32,
34    disabled: bool,
35    searchable: bool,
36    size: Size,
37}
38
39impl<'a> Select<'a> {
40    pub fn new(id_salt: impl std::hash::Hash, selected: &'a mut Option<usize>) -> Self {
41        Self {
42            id_salt: Id::new(id_salt),
43            selected,
44            options: Vec::new(),
45            placeholder: "Select…".to_string(),
46            width: None,
47            max_dropdown_height: 240.0,
48            disabled: false,
49            searchable: false,
50            size: Size::Medium,
51        }
52    }
53
54    /// Shorthand for a searchable select (combobox).
55    pub fn combobox(id_salt: impl std::hash::Hash, selected: &'a mut Option<usize>) -> Self {
56        Self::new(id_salt, selected).searchable()
57    }
58
59    pub fn option(mut self, label: impl Into<String>) -> Self {
60        self.options.push(label.into());
61        self
62    }
63    pub fn options<I, S>(mut self, options: I) -> Self
64    where
65        I: IntoIterator<Item = S>,
66        S: Into<String>,
67    {
68        self.options = options.into_iter().map(Into::into).collect();
69        self
70    }
71    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
72        self.placeholder = p.into();
73        self
74    }
75    pub fn width(mut self, w: f32) -> Self {
76        self.width = Some(w);
77        self
78    }
79    pub fn max_dropdown_height(mut self, h: f32) -> Self {
80        self.max_dropdown_height = h;
81        self
82    }
83    pub fn disabled(mut self, d: bool) -> Self {
84        self.disabled = d;
85        self
86    }
87    pub fn searchable(mut self) -> Self {
88        self.searchable = true;
89        self
90    }
91    pub fn size(mut self, s: Size) -> Self {
92        self.size = s;
93        self
94    }
95    pub fn small(self) -> Self {
96        self.size(Size::Small)
97    }
98    pub fn large(self) -> Self {
99        self.size(Size::Large)
100    }
101
102    /// Render the select. The returned [`Response`] reports `.changed()` when
103    /// the selection changes this frame.
104    pub fn show(self, ui: &mut Ui) -> Response {
105        let theme = Theme::get(ui.ctx());
106        let m = theme.metrics;
107        let c = theme.colors;
108        let radius = theme.corner();
109
110        let height = self.size.input_height(&m);
111        let width = self
112            .width
113            .unwrap_or_else(|| ui.available_width().min(240.0));
114
115        let sense = if self.disabled {
116            Sense::hover()
117        } else {
118            Sense::click()
119        };
120        let (rect, mut response) = ui.allocate_exact_size(vec2(width, height), sense);
121
122        // Derive popup / search-buffer ids from the caller-supplied salt so
123        // multiple selects on a page stay distinct and stable across frames.
124        let base = ui.make_persistent_id(self.id_salt);
125        let popup_id = base.with("popup");
126        let search_id = base.with("search");
127
128        let is_open = egui::Popup::is_id_open(ui.ctx(), popup_id);
129
130        // --- Trigger paint ---
131        if ui.is_rect_visible(rect) {
132            let painter = ui.painter();
133            let bg = if self.disabled {
134                mix(c.background, c.muted_background, 0.6)
135            } else {
136                c.background
137            };
138            painter.rect_filled(rect, radius, bg);
139
140            let border_color = if is_open {
141                c.ring
142            } else if response.hovered() {
143                mix(c.input_border, c.foreground, 0.25)
144            } else {
145                c.input_border
146            };
147            painter.rect_stroke(
148                rect,
149                radius,
150                Stroke::new(m.border_width, border_color),
151                egui::StrokeKind::Inside,
152            );
153
154            // Label / placeholder.
155            let chevron_w = 22.0;
156            let (text, color) = match self.selected.and_then(|i| self.options.get(i)) {
157                Some(label) => (label.clone(), c.foreground),
158                None => (self.placeholder.clone(), c.muted_foreground),
159            };
160            let color = if self.disabled {
161                mix(color, c.muted_foreground, 0.5)
162            } else {
163                color
164            };
165            let galley = ui.ctx().fonts_mut(|f| {
166                f.layout(
167                    text,
168                    egui::FontId::proportional(m.font_size_md),
169                    color,
170                    rect.width() - m.input_padding_x * 2.0 - chevron_w,
171                )
172            });
173            ui.painter().galley_with_override_text_color(
174                pos2(rect.left() + m.input_padding_x, rect.center().y - galley.size().y * 0.5),
175                galley,
176                color,
177            );
178
179            // Chevron.
180            draw_chevron(
181                ui.painter(),
182                pos2(rect.right() - chevron_w * 0.5 - 2.0, rect.center().y),
183                if self.disabled { mix(c.muted_foreground, c.background, 0.4) } else { c.muted_foreground },
184                is_open,
185            );
186
187            if is_open {
188                ui.painter().rect_stroke(
189                    rect.expand(2.0),
190                    radius,
191                    theme.focus_ring(),
192                    egui::StrokeKind::Outside,
193                );
194            }
195        }
196
197        if !self.disabled && response.hovered() {
198            ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
199        }
200
201        // --- Dropdown ---
202        let mut changed = false;
203
204        let popover_frame = Frame::new()
205            .fill(c.popover_background)
206            .stroke(theme.border_stroke())
207            .corner_radius(radius)
208            .inner_margin(Margin::same(4))
209            .shadow(egui::epaint::Shadow {
210                offset: [0, 4],
211                blur: 16,
212                spread: 0,
213                color: c.overlay,
214            });
215
216        egui::Popup::from_toggle_button_response(&response)
217            .id(popup_id)
218            .width(width)
219            .gap(4.0)
220            .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
221            .frame(popover_frame)
222            .show(|ui| {
223                ui.set_width(width - 8.0);
224
225                let mut query = if self.searchable {
226                    ui.data_mut(|d| d.get_temp::<String>(search_id))
227                        .unwrap_or_default()
228                } else {
229                    String::new()
230                };
231
232                if self.searchable {
233                    let r = ui.add(
234                        Input::new(&mut query)
235                            .placeholder("Search…")
236                            .width(width - 8.0),
237                    );
238                    if r.changed() {
239                        ui.data_mut(|d| d.insert_temp(search_id, query.clone()));
240                    }
241                    // Keep focus in the search box while the dropdown is open.
242                    if !r.has_focus() && !ui.memory(|m| m.focused().is_some()) {
243                        r.request_focus();
244                    }
245                    ui.add_space(4.0);
246                }
247
248                let needle = query.trim().to_lowercase();
249                egui::ScrollArea::vertical()
250                    .max_height(self.max_dropdown_height)
251                    .show(ui, |ui| {
252                        ui.set_width(ui.available_width());
253                        let mut any = false;
254                        for (i, opt) in self.options.iter().enumerate() {
255                            if !needle.is_empty() && !opt.to_lowercase().contains(&needle) {
256                                continue;
257                            }
258                            any = true;
259                            let item = ListItem::new(opt.clone())
260                                .selected(*self.selected == Some(i))
261                                .confirmed(*self.selected == Some(i));
262                            if ui.add(item).clicked() {
263                                *self.selected = Some(i);
264                                changed = true;
265                            }
266                        }
267                        if !any {
268                            ui.add(crate::label::Label::new("No results").muted());
269                        }
270                    });
271            });
272
273        if changed {
274            response.mark_changed();
275            egui::Popup::close_id(ui.ctx(), popup_id);
276            ui.data_mut(|d| d.insert_temp(search_id, String::new()));
277        }
278
279        response
280    }
281}
282
283fn draw_chevron(painter: &egui::Painter, center: egui::Pos2, color: egui::Color32, open: bool) {
284    let w = 4.5;
285    let h = 3.0;
286    let stroke = Stroke::new(1.5, color);
287    let (top, bottom) = if open { (h, -h) } else { (-h, h) };
288    painter.line_segment(
289        [pos2(center.x - w, center.y + top), pos2(center.x, center.y + bottom)],
290        stroke,
291    );
292    painter.line_segment(
293        [pos2(center.x + w, center.y + top), pos2(center.x, center.y + bottom)],
294        stroke,
295    );
296}