1use 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 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 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 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 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 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 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 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 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}