Skip to main content

egui_macview/controls/
combo_box.rs

1//! Authentic macOS AppKit Combo Box (`NSComboBox` equivalent, REQ-012-03).
2//!
3//! Grounded in macOS HIG and W3C DTCG tokens via [`MacTheme`].
4//!
5//! # Engineering Architecture & Apple AppKit Parity
6//!
7//! An authentic macOS combo box unites an editable text field (`NSTextField`) with a
8//! right-anchored popover dropdown trigger (`NSComboBoxCell`), matching AppKit's
9//! visual presentation and keyboard accessibility:
10//!
11//! - **Size-Variant Token Geometry**: Outer height ($24.0\,\text{pt}$ Regular, $20.0\,\text{pt}$ Small,
12//!   $16.0\,\text{pt}$ Mini) and button width ($19.0\,\text{pt}$ Regular, $16.0\,\text{pt}$ Small,
13//!   $14.0\,\text{pt}$ Mini) match `NSComboBox` metrics.
14//! - **Dropdown Chevron**: Symmetrical double-chevron glyph (`ChevronUpDown`) centered inside
15//!   the trigger button, highlighting on hover and press.
16//! - **Boundary-Clamped Popover**: Displays up to 5 visible items ($18.0\,\text{pt}$ row height,
17//!   $90.0\,\text{pt}$ max height) in an overlay `Area`. Automatically flips upward when
18//!   approaching the bottom of the screen viewport.
19//! - **Full Keyboard Navigation**:
20//!   - `Alt + ArrowDown`: Expands the dropdown popover.
21//!   - `ArrowDown` / `ArrowUp`: Navigates through filtered items with auto-scroll.
22//!   - `Return`: Selects the highlighted item, writes to buffer, and dismisses popover.
23//!   - `Escape`: Closes popover without altering buffer text.
24
25use 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/// Computed layout geometry for the combo box text field and trigger button header.
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct ComboBoxHeaderLayout {
37    /// Outer bounding rectangle.
38    pub total_rect: Rect,
39    /// Bounding rectangle for the right-hand dropdown trigger button.
40    pub button_rect: Rect,
41    /// Bounding rectangle for the editable text input field.
42    pub text_rect: Rect,
43    /// Resolved corner radius.
44    pub corner_radius: f32,
45}
46
47/// Resolved geometry for the popover dropdown list, clamped to screen boundaries.
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct ComboBoxPopoverGeometry {
50    /// Outer popover bounding box.
51    pub popover_rect: Rect,
52    /// Popover top-left screen position.
53    pub popover_pos: Pos2,
54    /// Single row height in points.
55    pub item_height: f32,
56    /// Total popover height in points.
57    pub popover_height: f32,
58    /// Total popover width in points.
59    pub popover_width: f32,
60}
61
62/// Authentic macOS AppKit Combo Box (`NSComboBox` equivalent).
63///
64/// Features an editable text field coupled with a right-anchored dropdown trigger,
65/// keyboard navigation (Alt+Down, Up/Down arrows, Return, Escape), and a filterable popover list.
66///
67/// # Example
68/// ```rust
69/// use egui_macview::{MacAppearance, MacComboBox, MacTheme};
70///
71/// # let ctx = egui::Context::default();
72/// # let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
73/// let theme = MacTheme::new(MacAppearance::Aqua);
74/// let mut font_name = String::from("SF Pro Text");
75/// let fonts = ["Helvetica Neue", "Menlo", "SF Mono", "SF Pro Display", "SF Pro Text"];
76///
77/// MacComboBox::new("font_combo", &mut font_name, &fonts)
78///     .width(180.0)
79///     .theme(&theme)
80///     .show(ui);
81/// # });
82/// # out.textures_delta.clear();
83/// ```
84pub 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    /// Creates a new combo box for an item slice and a mutable text buffer.
95    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    /// Sets the control size (`Regular`, `Small`, or `Mini`).
107    pub fn control_size(mut self, size: MacControlSize) -> Self {
108        self.control_size = size;
109        self
110    }
111
112    /// Injects dynamic theme.
113    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
114        self.theme = Some(theme);
115        self
116    }
117
118    /// Overrides default width.
119    pub fn width(mut self, width: f32) -> Self {
120        self.width = Some(width);
121        self
122    }
123
124    /// Resolves outer frame height in points.
125    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    /// Resolves right dropdown trigger button width in points.
136    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    /// Resolves left text padding inset in points.
147    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    /// Resolves corner radius in points.
158    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    /// Resolves popover single item row height in points ($18.0\,\text{pt}$).
169    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    /// Resolves visible items count before scrolling ($5$).
175    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    /// Resolves popover viewport height before scrolling ($90.0\,\text{pt}$).
181    pub fn popover_viewport_height(&self) -> f32 {
182        self.item_height() * (self.visible_items() as f32)
183    }
184
185    /// Phase 1: Computes the layout rectangles of the outer frame, text area, and trigger button.
186    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    /// Phase 2: Computes popover dimensions and screen-edge boundary clamping (with upward-flip detection).
211    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        // Screen-edge boundary detection: flip upward if dropdown exceeds bottom margin
230        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    /// Shows the combo box widget.
251    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        // Render Frame Background and Border
281        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        // Separator hairline between text field and button
296        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        // Trigger Button Highlight & Chevron Glyph
302        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        // Draw ChevronUpDown Glyphs
324        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        // Text input field
341        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        // Phase 3: Keyboard navigation & item filtering
353        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(&current_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            // Popover rendering
399            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                // Click outside to dismiss
464                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}