Skip to main content

egui_macview/controls/combo_box/
mod.rs

1//! Authentic macOS AppKit Combo Box (`NSComboBox` equivalent, REQ-012-03 & v0.6.5).
2
3mod popover;
4
5pub use popover::ComboBoxPopoverGeometry;
6
7use crate::controls::MacControlSize;
8use crate::layout::subpixel_snap_rect;
9use crate::theme::MacTheme;
10use egui::{
11    CornerRadius, FontId, Id, Key, Modifiers, Pos2, Rect, Response, Sense, Stroke, StrokeKind,
12    TextEdit, Ui, UiBuilder, Vec2,
13};
14use std::hash::Hash;
15
16/// Computed layout geometry for the combo box text field and trigger button header.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct ComboBoxHeaderLayout {
19    /// Outer bounding rectangle.
20    pub total_rect: Rect,
21    /// Bounding rectangle for the right-hand dropdown trigger button.
22    pub button_rect: Rect,
23    /// Bounding rectangle for the editable text input field.
24    pub text_rect: Rect,
25    /// Resolved corner radius.
26    pub corner_radius: f32,
27}
28
29/// Authentic macOS AppKit Combo Box (`NSComboBox` equivalent).
30///
31/// # Example
32/// ```rust
33/// use egui_macview::{MacAppearance, MacComboBox, MacTheme};
34/// # let ctx = egui::Context::default();
35/// # let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
36/// let theme = MacTheme::new(MacAppearance::Aqua);
37/// let mut font_name = String::from("SF Pro Text");
38/// let fonts = ["Helvetica Neue", "Menlo", "SF Mono", "SF Pro Display", "SF Pro Text"];
39/// MacComboBox::new("font_combo", &mut font_name, &fonts).width(180.0).theme(&theme).show(ui);
40/// # });
41/// # out.textures_delta.clear();
42/// ```
43pub struct MacComboBox<'a, T: AsRef<str>> {
44    pub(super) id_salt: Id,
45    pub(super) text: &'a mut String,
46    pub(super) items: &'a [T],
47    pub(super) control_size: MacControlSize,
48    pub(super) theme: Option<&'a MacTheme>,
49    pub(super) width: Option<f32>,
50    pub(super) fixed_rect: Option<Rect>,
51}
52
53impl<'a, T: AsRef<str>> MacComboBox<'a, T> {
54    /// Creates a new combo box for an item slice and a mutable text buffer.
55    pub fn new(id_salt: impl Hash + std::fmt::Debug, text: &'a mut String, items: &'a [T]) -> Self {
56        Self {
57            id_salt: Id::NULL.with(id_salt),
58            text,
59            items,
60            control_size: MacControlSize::Regular,
61            theme: None,
62            width: None,
63            fixed_rect: None,
64        }
65    }
66
67    /// Sets the control size (`Regular`, `Small`, or `Mini`).
68    pub fn control_size(mut self, size: MacControlSize) -> Self {
69        self.control_size = size;
70        self
71    }
72
73    /// Injects dynamic theme.
74    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
75        self.theme = Some(theme);
76        self
77    }
78
79    /// Overrides default width.
80    pub fn width(mut self, width: f32) -> Self {
81        self.width = Some(width);
82        self
83    }
84
85    /// Pins the combo box to an exact NIB rectangle.
86    pub fn fixed_rect(mut self, rect: Rect) -> Self {
87        self.fixed_rect = Some(rect);
88        self
89    }
90
91    /// Resolves outer frame height in points.
92    pub fn frame_height(&self) -> f32 {
93        let t = MacTheme::resolve_or_default(self.theme);
94        let m = t.combo_box();
95        match self.control_size {
96            MacControlSize::Regular | MacControlSize::Large => m.regular_height.points(),
97            MacControlSize::Small | MacControlSize::CompactInfobox => m.small_height.points(),
98            MacControlSize::Mini => m.mini_height.points(),
99        }
100    }
101
102    /// Resolves visual bezel height in points (`20.0pt` Regular, `16.0pt` Small, `13.0pt` Mini).
103    pub fn visual_height(&self) -> f32 {
104        let t = MacTheme::resolve_or_default(self.theme);
105        let m = t.combo_box();
106        match self.control_size {
107            MacControlSize::Regular | MacControlSize::Large => m.regular_visual_height.points(),
108            MacControlSize::Small | MacControlSize::CompactInfobox => m.small_visual_height.points(),
109            MacControlSize::Mini => m.mini_visual_height.points(),
110        }
111    }
112
113    /// Resolves right dropdown trigger button width in points.
114    pub fn button_width(&self) -> f32 {
115        let t = MacTheme::resolve_or_default(self.theme);
116        let m = t.combo_box();
117        match self.control_size {
118            MacControlSize::Regular | MacControlSize::Large => m.regular_button_width.points(),
119            MacControlSize::Small | MacControlSize::CompactInfobox => m.small_button_width.points(),
120            MacControlSize::Mini => m.mini_button_width.points(),
121        }
122    }
123
124    /// Resolves left text padding inset in points.
125    pub fn text_inset(&self) -> f32 {
126        let t = MacTheme::resolve_or_default(self.theme);
127        let m = t.combo_box();
128        match self.control_size {
129            MacControlSize::Regular | MacControlSize::Large => m.regular_inset.points(),
130            MacControlSize::Small | MacControlSize::CompactInfobox => m.small_inset.points(),
131            MacControlSize::Mini => m.mini_inset.points(),
132        }
133    }
134
135    /// Resolves corner radius in points.
136    pub fn corner_radius(&self) -> f32 {
137        let t = MacTheme::resolve_or_default(self.theme);
138        let m = t.combo_box();
139        match self.control_size {
140            MacControlSize::Regular | MacControlSize::Large => m.regular_corner_radius.points(),
141            MacControlSize::Small | MacControlSize::CompactInfobox => m.small_corner_radius.points(),
142            MacControlSize::Mini => m.mini_corner_radius.points(),
143        }
144    }
145
146    /// Resolves popover single item row height in points ($18.0\,\text{pt}$).
147    pub fn item_height(&self) -> f32 {
148        MacTheme::resolve_or_default(self.theme).combo_box().item_height.points()
149    }
150
151    /// Resolves visible items count before scrolling ($5$).
152    pub fn visible_items(&self) -> usize {
153        MacTheme::resolve_or_default(self.theme).combo_box().visible_items.points() as usize
154    }
155
156    /// Resolves popover viewport height before scrolling ($90.0\,\text{pt}$).
157    pub fn popover_viewport_height(&self) -> f32 {
158        self.item_height() * (self.visible_items() as f32)
159    }
160
161    /// Phase 1: Computes the layout rectangles of the outer frame, text area, and trigger button.
162    pub fn compute_header_layout(&self, ui: &Ui) -> ComboBoxHeaderLayout {
163        let (height, btn_w, corner_r) = (self.frame_height(), self.button_width(), self.corner_radius());
164        let desired_w = self.fixed_rect.map(|r| r.width()).or(self.width)
165            .unwrap_or_else(|| ui.available_width().max(btn_w * 2.0));
166        let raw = self.fixed_rect.unwrap_or_else(|| Rect::from_min_size(ui.cursor().min, Vec2::new(desired_w, height)));
167        let rect = subpixel_snap_rect(raw, ui.ctx().pixels_per_point());
168        let text_inset = self.text_inset();
169        ComboBoxHeaderLayout {
170            total_rect: rect,
171            button_rect: Rect::from_min_max(Pos2::new(rect.max.x - btn_w, rect.min.y), rect.max),
172            text_rect: Rect::from_min_max(Pos2::new(rect.min.x + text_inset, rect.min.y), Pos2::new(rect.max.x - btn_w, rect.max.y)),
173            corner_radius: corner_r,
174        }
175    }
176
177    /// Phase 2: Computes popover dimensions and screen-edge boundary clamping.
178    pub fn compute_popover_geometry(&self, ui: &Ui, header_rect: Rect, item_count: usize) -> ComboBoxPopoverGeometry {
179        popover::compute_popover_geometry(self, ui, header_rect, item_count)
180    }
181
182    /// Shows the combo box widget.
183    pub fn show(mut self, ui: &mut Ui) -> Response {
184        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
185        let theme: &MacTheme = &theme_cow;
186        let metrics = theme.combo_box();
187        let layout = self.compute_header_layout(ui);
188        let enabled = ui.is_enabled();
189        let sense = if enabled { Sense::click() } else { Sense::hover() };
190        let ppp = ui.ctx().pixels_per_point();
191        let (frame_rect, visual_rect, mut response) = if let Some(fixed) = self.fixed_rect {
192            let sf = subpixel_snap_rect(fixed, ppp);
193            let resp = ui.interact(sf, self.id_salt.with("fixed_combo"), sense);
194            (sf, theme.clamp_nib_visual_rect(sf, self.visual_height(), ppp), resp)
195        } else {
196            let (r, resp) = ui.allocate_exact_size(layout.total_rect.size(), sense);
197            let snapped = subpixel_snap_rect(r, ppp);
198            (snapped, snapped, resp)
199        };
200
201        let open_id = self.id_salt.with("popover_open");
202        let mut is_open = enabled && ui.data_mut(|d| d.get_temp::<bool>(open_id).unwrap_or(false));
203        let selected_item_id = self.id_salt.with("popover_sel_idx");
204        let stored_sel_idx = ui.data_mut(|d| d.get_temp::<usize>(selected_item_id));
205
206        let (btn_w, text_inset) = (self.button_width(), self.text_inset());
207        let btn_rect = Rect::from_min_max(Pos2::new(visual_rect.max.x - btn_w, visual_rect.min.y), visual_rect.max);
208        let text_rect = Rect::from_min_max(
209            Pos2::new(visual_rect.min.x + text_inset, visual_rect.min.y),
210            Pos2::new(visual_rect.max.x - btn_w, visual_rect.max.y),
211        );
212
213        if enabled && (ui.input_mut(|i| i.consume_key(Modifiers::ALT, Key::ArrowDown))
214            || ui.input(|i| i.modifiers.alt && i.key_pressed(Key::ArrowDown)))
215        {
216            is_open = true;
217        }
218
219        let painter = ui.painter_at(frame_rect);
220        let border_col = if enabled && response.has_focus() {
221            theme.control_accent()
222        } else {
223            theme.apply_enabled(theme.separator(), enabled)
224        };
225        let bg_fill = theme.apply_enabled(theme.control_background(), enabled);
226        painter.rect(visual_rect, CornerRadius::same(layout.corner_radius as u8), bg_fill, Stroke::new(1.0, border_col), StrokeKind::Inside);
227        painter.line_segment([Pos2::new(btn_rect.min.x, visual_rect.min.y), Pos2::new(btn_rect.min.x, visual_rect.max.y)], Stroke::new(1.0, theme.apply_enabled(theme.separator(), enabled)));
228
229        let pointer_pos = ui.input(|i| i.pointer.hover_pos());
230        let btn_hovered = enabled && pointer_pos.is_some_and(|p| btn_rect.contains(p));
231        let btn_down = btn_hovered && ui.input(|i| i.pointer.primary_down());
232        let btn_radius = CornerRadius { nw: 0, sw: 0, ne: layout.corner_radius as u8, se: layout.corner_radius as u8 };
233        if btn_down {
234            painter.rect_filled(btn_rect, btn_radius, theme.selected_control());
235        } else if btn_hovered {
236            painter.rect_filled(btn_rect, btn_radius, theme.control());
237        }
238        if btn_hovered && ui.input(|i| i.pointer.primary_clicked()) {
239            is_open = !is_open;
240        }
241
242        // Right accent button badge (`NSComboBoxCell` 17pt badge, 2pt inset, 4pt radius)
243        let (badge_w, badge_inset, badge_r) = (metrics.badge_button_width.points(), metrics.button_inset.points(), metrics.button_corner_radius.points());
244        let badge_rect = Rect::from_min_max(
245            Pos2::new((visual_rect.max.x - badge_inset - badge_w).max(btn_rect.min.x), visual_rect.min.y + badge_inset),
246            Pos2::new(visual_rect.max.x - badge_inset, visual_rect.max.y - badge_inset),
247        );
248        let badge_fill = if !enabled { theme.control().gamma_multiply(theme.disabled_opacity()) } else if btn_down { theme.selected_control() } else { theme.control_accent() };
249        painter.rect_filled(badge_rect, CornerRadius::same(badge_r as u8), badge_fill);
250
251        let (cx, cy) = (badge_rect.center().x, badge_rect.center().y);
252        let (ch_hw, ch_hh) = (metrics.chevron_half_width.points(), metrics.chevron_half_height.points());
253        let ch_stroke = Stroke::new(metrics.chevron_stroke_width.points(), theme.apply_enabled(theme.on_accent_text(), enabled));
254        painter.line_segment([Pos2::new(cx - ch_hw, cy - ch_hh * 0.5), Pos2::new(cx, cy + ch_hh)], ch_stroke);
255        painter.line_segment([Pos2::new(cx, cy + ch_hh), Pos2::new(cx + ch_hw, cy - ch_hh * 0.5)], ch_stroke);
256
257        let mut child_ui = ui.new_child(UiBuilder::new().id_salt(self.id_salt.with("text_edit")).max_rect(text_rect));
258        let font_sz = match self.control_size {
259            MacControlSize::Regular | MacControlSize::Large => theme.text_field().regular.font_size.points(),
260            MacControlSize::Small | MacControlSize::CompactInfobox => theme.text_field().small.font_size.points(),
261            MacControlSize::Mini => theme.text_field().mini.font_size.points(),
262        };
263        let edit_id = self.id_salt.with("combo_input_id");
264        let is_edit_focused = enabled && ui.memory(|m| m.has_focus(edit_id));
265        if is_edit_focused {
266            let edit_res = child_ui.add_sized(
267                text_rect.size(),
268                TextEdit::singleline(self.text).id(edit_id).font(FontId::proportional(font_sz)).text_color(theme.label()).frame(egui::Frame::NONE),
269            );
270            if edit_res.changed() {
271                response.mark_changed();
272                is_open = true;
273            }
274        } else {
275            let click_res = child_ui.interact(text_rect, edit_id, sense);
276            if enabled && click_res.clicked() {
277                ui.memory_mut(|m| m.request_focus(edit_id));
278            }
279            if !self.text.is_empty() {
280                let lm = theme.label_metrics();
281                let (baseline_from_top, ascender) = match self.control_size {
282                    MacControlSize::Regular | MacControlSize::Large => (metrics.regular_first_baseline_offset.points(), lm.regular_ascender.points()),
283                    MacControlSize::Small | MacControlSize::CompactInfobox => (metrics.small_first_baseline_offset.points(), lm.small_ascender.points()),
284                    MacControlSize::Mini => (metrics.mini_first_baseline_offset.points(), lm.mini_ascender.points()),
285                };
286                let text_col = theme.apply_enabled(theme.label(), enabled);
287                let galley = theme.layout_tracked_galley(ui, self.text.as_str(), FontId::proportional(font_sz), text_col, text_rect.width().max(0.0));
288                let text_y = theme.baseline_text_y(visual_rect, baseline_from_top, ascender, ppp);
289                painter.with_clip_rect(text_rect).galley(Pos2::new(text_rect.left(), text_y), galley, text_col);
290            }
291        }
292
293        popover::handle_combo_popover(
294            &mut self, ui, theme, visual_rect, layout.corner_radius, &mut is_open,
295            stored_sel_idx, open_id, selected_item_id, pointer_pos, &mut response,
296        );
297        response
298    }
299}