1use crate::controls::MacControlSize;
26use crate::layout::subpixel_snap_rect;
27use crate::theme::MacTheme;
28use egui::{
29 Area, CornerRadius, Id, Key, Modifiers, Order, Pos2, Rect, Response, ScrollArea, Sense, Stroke,
30 StrokeKind, TextEdit, Ui, UiBuilder, Vec2,
31};
32use std::hash::Hash;
33
34#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct ComboBoxHeaderLayout {
37 pub total_rect: Rect,
39 pub button_rect: Rect,
41 pub text_rect: Rect,
43 pub corner_radius: f32,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct ComboBoxPopoverGeometry {
50 pub popover_rect: Rect,
52 pub popover_pos: Pos2,
54 pub item_height: f32,
56 pub popover_height: f32,
58 pub popover_width: f32,
60}
61
62pub struct MacComboBox<'a, T: AsRef<str>> {
85 id_salt: Id,
86 text: &'a mut String,
87 items: &'a [T],
88 control_size: MacControlSize,
89 theme: Option<&'a MacTheme>,
90 width: Option<f32>,
91}
92
93impl<'a, T: AsRef<str>> MacComboBox<'a, T> {
94 pub fn new(id_salt: impl Hash + std::fmt::Debug, text: &'a mut String, items: &'a [T]) -> Self {
96 Self {
97 id_salt: Id::NULL.with(id_salt),
98 text,
99 items,
100 control_size: MacControlSize::Regular,
101 theme: None,
102 width: None,
103 }
104 }
105
106 pub fn control_size(mut self, size: MacControlSize) -> Self {
108 self.control_size = size;
109 self
110 }
111
112 pub fn theme(mut self, theme: &'a MacTheme) -> Self {
114 self.theme = Some(theme);
115 self
116 }
117
118 pub fn width(mut self, width: f32) -> Self {
120 self.width = Some(width);
121 self
122 }
123
124 pub fn frame_height(&self) -> f32 {
126 let theme_cow = MacTheme::resolve_or_default(self.theme);
127 let m = theme_cow.combo_box();
128 match self.control_size {
129 MacControlSize::Regular | MacControlSize::Large => m.regular_height.points(),
130 MacControlSize::Small | MacControlSize::CompactInfobox => m.small_height.points(),
131 MacControlSize::Mini => m.mini_height.points(),
132 }
133 }
134
135 pub fn button_width(&self) -> f32 {
137 let theme_cow = MacTheme::resolve_or_default(self.theme);
138 let m = theme_cow.combo_box();
139 match self.control_size {
140 MacControlSize::Regular | MacControlSize::Large => m.regular_button_width.points(),
141 MacControlSize::Small | MacControlSize::CompactInfobox => m.small_button_width.points(),
142 MacControlSize::Mini => m.mini_button_width.points(),
143 }
144 }
145
146 pub fn text_inset(&self) -> f32 {
148 let theme_cow = MacTheme::resolve_or_default(self.theme);
149 let m = theme_cow.combo_box();
150 match self.control_size {
151 MacControlSize::Regular | MacControlSize::Large => m.regular_inset.points(),
152 MacControlSize::Small | MacControlSize::CompactInfobox => m.small_inset.points(),
153 MacControlSize::Mini => m.mini_inset.points(),
154 }
155 }
156
157 pub fn corner_radius(&self) -> f32 {
159 let theme_cow = MacTheme::resolve_or_default(self.theme);
160 let m = theme_cow.combo_box();
161 match self.control_size {
162 MacControlSize::Regular | MacControlSize::Large => m.regular_corner_radius.points(),
163 MacControlSize::Small | MacControlSize::CompactInfobox => m.small_corner_radius.points(),
164 MacControlSize::Mini => m.mini_corner_radius.points(),
165 }
166 }
167
168 pub fn item_height(&self) -> f32 {
170 let theme_cow = MacTheme::resolve_or_default(self.theme);
171 theme_cow.combo_box().item_height.points()
172 }
173
174 pub fn visible_items(&self) -> usize {
176 let theme_cow = MacTheme::resolve_or_default(self.theme);
177 theme_cow.combo_box().visible_items.points() as usize
178 }
179
180 pub fn popover_viewport_height(&self) -> f32 {
182 self.item_height() * (self.visible_items() as f32)
183 }
184
185 pub fn compute_header_layout(&self, ui: &Ui) -> ComboBoxHeaderLayout {
187 let height = self.frame_height();
188 let btn_w = self.button_width();
189 let corner_r = self.corner_radius();
190 let desired_w = self.width.unwrap_or_else(|| ui.available_width().max(btn_w * 2.0));
191
192 let rect = Rect::from_min_size(ui.cursor().min, Vec2::new(desired_w, height));
193 let rect = subpixel_snap_rect(rect, ui.ctx().pixels_per_point());
194
195 let text_inset = self.text_inset();
196 let btn_rect = Rect::from_min_max(Pos2::new(rect.max.x - btn_w, rect.min.y), rect.max);
197 let text_rect = Rect::from_min_max(
198 Pos2::new(rect.min.x + text_inset, rect.min.y),
199 Pos2::new(rect.max.x - btn_w, rect.max.y),
200 );
201
202 ComboBoxHeaderLayout {
203 total_rect: rect,
204 button_rect: btn_rect,
205 text_rect,
206 corner_radius: corner_r,
207 }
208 }
209
210 pub fn compute_popover_geometry(
212 &self,
213 ui: &Ui,
214 header_rect: Rect,
215 item_count: usize,
216 ) -> ComboBoxPopoverGeometry {
217 let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
218 let metrics = theme_cow.combo_box();
219 let item_h = metrics.item_height.points();
220 let visible_count = (metrics.visible_items.points() as usize).min(item_count);
221 let pop_h = item_h * (visible_count as f32);
222 let pop_w = header_rect.width();
223
224 let screen_rect = ui
225 .ctx()
226 .input(|i| i.raw.screen_rect)
227 .unwrap_or_else(|| ui.clip_rect());
228
229 let pop_y = if header_rect.max.y + 2.0 + pop_h > screen_rect.bottom()
231 && header_rect.min.y - 2.0 - pop_h >= screen_rect.top()
232 {
233 header_rect.min.y - 2.0 - pop_h
234 } else {
235 header_rect.max.y + 2.0
236 };
237
238 let pop_pos = Pos2::new(header_rect.min.x, pop_y);
239 let pop_rect = Rect::from_min_size(pop_pos, Vec2::new(pop_w, pop_h));
240
241 ComboBoxPopoverGeometry {
242 popover_rect: pop_rect,
243 popover_pos: pop_pos,
244 item_height: item_h,
245 popover_height: pop_h,
246 popover_width: pop_w,
247 }
248 }
249
250 pub fn show(self, ui: &mut Ui) -> Response {
252 let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
253 let theme: &MacTheme = &theme_cow;
254 let metrics = theme.combo_box();
255
256 let layout = self.compute_header_layout(ui);
257 let (rect, mut response) = ui.allocate_exact_size(layout.total_rect.size(), Sense::click());
258 let rect = subpixel_snap_rect(rect, ui.ctx().pixels_per_point());
259
260 let open_id = self.id_salt.with("popover_open");
261 let mut is_open = ui.data_mut(|d| d.get_temp::<bool>(open_id).unwrap_or(false));
262 let selected_item_id = self.id_salt.with("popover_sel_idx");
263 let stored_sel_idx = ui.data_mut(|d| d.get_temp::<usize>(selected_item_id));
264 let mut final_sel_idx = stored_sel_idx.unwrap_or(0);
265
266 let btn_w = self.button_width();
267 let text_inset = self.text_inset();
268 let btn_rect = Rect::from_min_max(Pos2::new(rect.max.x - btn_w, rect.min.y), rect.max);
269 let text_rect = Rect::from_min_max(
270 Pos2::new(rect.min.x + text_inset, rect.min.y),
271 Pos2::new(rect.max.x - btn_w, rect.max.y),
272 );
273
274 if ui.input_mut(|i| i.consume_key(Modifiers::ALT, Key::ArrowDown))
275 || ui.input(|i| i.modifiers.alt && i.key_pressed(Key::ArrowDown))
276 {
277 is_open = true;
278 }
279
280 let painter = ui.painter_at(rect);
282 let border_stroke = if response.has_focus() {
283 Stroke::new(1.0, theme.control_accent())
284 } else {
285 Stroke::new(1.0, theme.separator())
286 };
287 painter.rect(
288 rect,
289 CornerRadius::same(layout.corner_radius as u8),
290 theme.control_background(),
291 border_stroke,
292 StrokeKind::Inside,
293 );
294
295 painter.line_segment(
297 [Pos2::new(btn_rect.min.x, rect.min.y), Pos2::new(btn_rect.min.x, rect.max.y)],
298 Stroke::new(1.0, theme.separator()),
299 );
300
301 let pointer_pos = ui.input(|i| i.pointer.hover_pos());
303 let btn_hovered = pointer_pos.is_some_and(|p| btn_rect.contains(p));
304 let btn_down = btn_hovered && ui.input(|i| i.pointer.primary_down());
305
306 let btn_radius = CornerRadius {
307 nw: 0,
308 sw: 0,
309 ne: layout.corner_radius as u8,
310 se: layout.corner_radius as u8,
311 };
312
313 if btn_down {
314 painter.rect_filled(btn_rect, btn_radius, theme.selected_control());
315 } else if btn_hovered {
316 painter.rect_filled(btn_rect, btn_radius, theme.control());
317 }
318
319 if btn_hovered && ui.input(|i| i.pointer.primary_clicked()) {
320 is_open = !is_open;
321 }
322
323 let (cx, cy) = (btn_rect.center().x, btn_rect.center().y);
325 let half_ch = (btn_w * 0.5) * 0.5;
326 let chevron_color = if btn_hovered { theme.label() } else { theme.secondary_label() };
327
328 let up_p1 = Pos2::new(cx - half_ch, cy - 2.0);
329 let up_p2 = Pos2::new(cx, cy - half_ch - 2.0);
330 let up_p3 = Pos2::new(cx + half_ch, cy - 2.0);
331 painter.line_segment([up_p1, up_p2], Stroke::new(1.0, chevron_color));
332 painter.line_segment([up_p2, up_p3], Stroke::new(1.0, chevron_color));
333
334 let down_p1 = Pos2::new(cx - half_ch, cy + 2.0);
335 let down_p2 = Pos2::new(cx, cy + half_ch + 2.0);
336 let down_p3 = Pos2::new(cx + half_ch, cy + 2.0);
337 painter.line_segment([down_p1, down_p2], Stroke::new(1.0, chevron_color));
338 painter.line_segment([down_p2, down_p3], Stroke::new(1.0, chevron_color));
339
340 let mut child_ui = ui.new_child(
342 UiBuilder::new()
343 .id_salt(self.id_salt.with("text_edit"))
344 .max_rect(text_rect),
345 );
346 let edit_res = child_ui.add_sized(text_rect.size(), TextEdit::singleline(self.text).frame(egui::Frame::NONE));
347 if edit_res.changed() {
348 response.mark_changed();
349 is_open = true;
350 }
351
352 if is_open {
354 let current_text_lower = self.text.to_lowercase();
355 let is_exact_match = self
356 .items
357 .iter()
358 .any(|it| it.as_ref().eq_ignore_ascii_case(self.text.as_str()));
359 let filtered: Vec<(usize, &str)> = self
360 .items
361 .iter()
362 .enumerate()
363 .map(|(i, item)| (i, item.as_ref()))
364 .filter(|(_, s)| {
365 current_text_lower.is_empty()
366 || is_exact_match
367 || s.to_lowercase().contains(¤t_text_lower)
368 })
369 .collect();
370
371 let matching_idx = filtered
372 .iter()
373 .position(|(_, s)| s.eq_ignore_ascii_case(self.text.as_str()))
374 .unwrap_or(0);
375 let mut sel_idx = stored_sel_idx
376 .unwrap_or(matching_idx)
377 .min(filtered.len().saturating_sub(1));
378
379 let mut nav_changed = false;
380 if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) {
381 is_open = false;
382 } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowDown)) {
383 if !filtered.is_empty() {
384 sel_idx = (sel_idx + 1).min(filtered.len() - 1);
385 nav_changed = true;
386 }
387 } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowUp)) {
388 sel_idx = sel_idx.saturating_sub(1);
389 nav_changed = true;
390 } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) {
391 if let Some((_, selected_str)) = filtered.get(sel_idx) {
392 *self.text = (*selected_str).to_string();
393 response.mark_changed();
394 is_open = false;
395 }
396 }
397
398 if is_open && !filtered.is_empty() {
400 let pop_geom = self.compute_popover_geometry(ui, rect, filtered.len());
401 let area = Area::new(self.id_salt.with("popover_area"))
402 .order(Order::Foreground)
403 .fixed_pos(pop_geom.popover_pos);
404
405 area.show(ui.ctx(), |pop_ui| {
406 pop_ui.set_clip_rect(pop_geom.popover_rect);
407 let pop_painter = pop_ui.painter_at(pop_geom.popover_rect);
408 pop_painter.rect(
409 pop_geom.popover_rect,
410 CornerRadius::same(layout.corner_radius as u8),
411 theme.control_background(),
412 Stroke::new(1.0, theme.separator()),
413 StrokeKind::Inside,
414 );
415
416 ScrollArea::vertical()
417 .max_height(pop_geom.popover_height)
418 .show(pop_ui, |scroll_ui| {
419 for (idx, (_, item_str)) in filtered.iter().enumerate() {
420 let (row_rect, row_resp) = scroll_ui
421 .allocate_exact_size(Vec2::new(pop_geom.popover_width, pop_geom.item_height), Sense::click());
422
423 let is_selected = idx == sel_idx;
424 if is_selected && nav_changed {
425 row_resp.scroll_to_me(Some(egui::Align::Center));
426 }
427
428 let row_hovered = row_resp.hovered();
429 if row_hovered || is_selected {
430 scroll_ui.painter().rect_filled(
431 row_rect,
432 CornerRadius::same(0),
433 theme.selected_control(),
434 );
435 }
436
437 let text_color = if row_hovered || is_selected {
438 theme.on_accent_text()
439 } else {
440 theme.label()
441 };
442
443 let galley = scroll_ui.painter().layout_no_wrap(
444 item_str.to_string(),
445 scroll_ui.style().text_styles.get(&egui::TextStyle::Body).cloned().unwrap_or_default(),
446 text_color,
447 );
448 scroll_ui.painter().galley(
449 Pos2::new(row_rect.min.x + metrics.intercell_spacing_x.points(), row_rect.center().y - galley.size().y * 0.5),
450 galley,
451 text_color,
452 );
453
454 if row_resp.clicked() {
455 *self.text = (*item_str).to_string();
456 response.mark_changed();
457 is_open = false;
458 }
459 }
460 });
461 });
462
463 if ui.input(|i| i.pointer.primary_clicked()) {
465 if let Some(pos) = pointer_pos {
466 if !rect.contains(pos) && !pop_geom.popover_rect.contains(pos) {
467 is_open = false;
468 }
469 }
470 }
471 final_sel_idx = sel_idx;
472 }
473 }
474
475 ui.data_mut(|d| {
476 d.insert_temp(open_id, is_open);
477 if is_open {
478 d.insert_temp(selected_item_id, final_sel_idx);
479 } else {
480 d.remove_temp::<usize>(selected_item_id);
481 }
482 });
483
484 response
485 }
486}