Skip to main content

egui_macview/controls/
popup_button.rs

1//! Authentic macOS AppKit Pop-Up Button (NSPopUpButton equivalent).
2//!
3//! Grounded in macOS HIG and W3C DTCG tokens via `MacTheme`.
4//! Fully data-driven: zero hardcoded colors or dimensions.
5
6use crate::layout::subpixel_snap_rect;
7use crate::theme::MacTheme;
8use egui::{
9    Align2, Area, Color32, CornerRadius, FontId, Key, Modifiers, Order, Pos2, Rect, Response,
10    Sense, Stroke, StrokeKind, Ui, Vec2, Widget,
11};
12
13/// Authentic macOS AppKit Pop-Up Button (`NSPopUpButton` equivalent).
14///
15/// Features authentic dual stacked chevrons, floating menu overlay,
16/// keyboard navigation (Up/Down arrows, Space, Return, Escape), and theme-driven styling.
17///
18/// # Example
19///
20/// ```rust
21/// use egui_macview::{MacPopUpButton, MacTheme};
22///
23/// # let ctx = egui::Context::default();
24/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
25/// let theme = MacTheme::default();
26/// let mut selected = 0;
27/// let options = ["Option 1", "Option 2", "Option 3"];
28///
29/// MacPopUpButton::new(&mut selected, &options)
30///     .theme(&theme)
31///     .show(ui);
32/// # });
33/// # output.textures_delta.clear();
34/// ```
35pub struct MacPopUpButton<'a> {
36    id: Option<egui::Id>,
37    selected_index: &'a mut usize,
38    items: &'a [&'a str],
39    theme: Option<&'a MacTheme>,
40    width: Option<f32>,
41    height: Option<f32>,
42    corner_radius: Option<f32>,
43    enabled: bool,
44}
45
46impl<'a> MacPopUpButton<'a> {
47    /// Creates a new popup dropdown button for an item slice and a mutable selected index.
48    pub fn new(selected_index: &'a mut usize, items: &'a [&'a str]) -> Self {
49        Self {
50            id: None,
51            selected_index,
52            items,
53            theme: None,
54            width: None,
55            height: None,
56            corner_radius: None,
57            enabled: true,
58        }
59    }
60
61    /// Sets an explicit widget `Id`.
62    pub fn id(mut self, id_source: impl std::hash::Hash + std::fmt::Debug) -> Self {
63        self.id = Some(egui::Id::NULL.with(id_source));
64        self
65    }
66
67    /// Sets the dynamic theme.
68    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
69        self.theme = Some(theme);
70        self
71    }
72
73    /// Overrides default width.
74    pub fn width(mut self, width: f32) -> Self {
75        self.width = Some(width);
76        self
77    }
78
79    /// Overrides default height.
80    pub fn height(mut self, height: f32) -> Self {
81        self.height = Some(height);
82        self
83    }
84
85    /// Overrides default corner radius.
86    pub fn corner_radius(mut self, r: f32) -> Self {
87        self.corner_radius = Some(r);
88        self
89    }
90
91    /// Enables or disables the control.
92    pub fn enabled(mut self, enabled: bool) -> Self {
93        self.enabled = enabled;
94        self
95    }
96
97    /// Renders the popup button inside the specified UI container.
98    pub fn show(self, ui: &mut Ui) -> Response {
99        ui.add(self)
100    }
101}
102
103impl<'a> Widget for MacPopUpButton<'a> {
104    fn ui(self, ui: &mut Ui) -> Response {
105        let theme = MacTheme::resolve(self.theme, ui.ctx());
106
107        let metrics = theme.popup_button();
108        let height = self.height.unwrap_or_else(|| metrics.height.points());
109        let corner_radius = self
110            .corner_radius
111            .unwrap_or_else(|| metrics.corner_radius.points());
112        let padding_h = metrics.padding_horizontal.points();
113        let chevron_size = metrics.chevron_size.points();
114        let chevron_stroke = metrics.chevron_stroke_width.points();
115        let chevron_spacing = metrics.chevron_spacing.points();
116
117        let font_id = FontId::proportional(metrics.font_size.points());
118        if !self.items.is_empty() && *self.selected_index >= self.items.len() {
119            *self.selected_index = self.items.len() - 1;
120        }
121        let current_text = self.items.get(*self.selected_index).copied().unwrap_or("");
122        let current_galley = ui.painter().layout_no_wrap(
123            current_text.to_owned(),
124            font_id.clone(),
125            Color32::PLACEHOLDER,
126        );
127        let mut cached_items: Option<(f32, Vec<std::sync::Arc<egui::Galley>>)> = None;
128        let layout_items =
129            |ui: &Ui,
130             cur: &std::sync::Arc<egui::Galley>,
131             cache: &mut Option<(f32, Vec<std::sync::Arc<egui::Galley>>)>| {
132                if let Some(existing) = cache.clone() {
133                    return existing;
134                }
135                let mut max_w = cur.size().x;
136                let mut galleys = Vec::with_capacity(self.items.len());
137                for &item in self.items {
138                    let g = if std::ptr::eq(item, current_text) {
139                        cur.clone()
140                    } else {
141                        ui.painter().layout_no_wrap(
142                            item.to_owned(),
143                            font_id.clone(),
144                            Color32::PLACEHOLDER,
145                        )
146                    };
147                    max_w = max_w.max(g.size().x);
148                    galleys.push(g);
149                }
150                let w = max_w + padding_h * 2.0 + chevron_size + chevron_spacing;
151                *cache = Some((w, galleys.clone()));
152                (w, galleys)
153            };
154        let desired_width = self
155            .width
156            .unwrap_or_else(|| layout_items(ui, &current_galley, &mut cached_items).0);
157
158        let sense = if self.enabled {
159            Sense::click()
160        } else {
161            Sense::hover()
162        };
163        let (raw_rect, mut response) = if let Some(explicit_id) = self.id {
164            let (r, _) = ui.allocate_exact_size(Vec2::new(desired_width, height), Sense::hover());
165            let resp = ui.interact(r, explicit_id, sense);
166            (r, resp)
167        } else {
168            ui.allocate_exact_size(Vec2::new(desired_width, height), sense)
169        };
170        let rect = subpixel_snap_rect(raw_rect, ui.ctx().pixels_per_point());
171
172        // Keyboard arrow navigation when focused
173        if self.enabled && response.has_focus() {
174            if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowDown)) {
175                if *self.selected_index + 1 < self.items.len() {
176                    *self.selected_index += 1;
177                    response.mark_changed();
178                }
179            } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowUp))
180                && *self.selected_index > 0
181            {
182                *self.selected_index -= 1;
183                response.mark_changed();
184            }
185        }
186
187        let painter = ui.painter_at(rect);
188
189        // Dynamic background
190        let bg_color = if !self.enabled {
191            theme.control_background()
192        } else if response.is_pointer_button_down_on() {
193            theme.selected_control_inactive()
194        } else if response.hovered() {
195            theme.control()
196        } else {
197            theme.control_background()
198        };
199
200        let border_stroke = if response.has_focus() {
201            Stroke::new(metrics.border_width.points(), theme.control_accent())
202        } else {
203            Stroke::new(metrics.border_width.points(), theme.separator())
204        };
205
206        // Outer focus ring
207        if self.enabled && response.has_focus() {
208            theme.paint_focus_ring(&painter, rect, corner_radius);
209        }
210
211        // Draw bezel
212        painter.rect(
213            rect,
214            CornerRadius::same(corner_radius as u8),
215            bg_color,
216            border_stroke,
217            StrokeKind::Inside,
218        );
219
220        // Draw text label
221        let text_color = if !self.enabled {
222            theme.placeholder_text()
223        } else {
224            theme.label()
225        };
226
227        let label_pos = Align2::LEFT_CENTER
228            .anchor_size(
229                Pos2::new(rect.left() + padding_h, rect.center().y),
230                current_galley.size(),
231            )
232            .min;
233        painter.galley(label_pos, current_galley.clone(), text_color);
234
235        // Draw trailing double chevrons (up/down pair)
236        let chevron_color = if !self.enabled {
237            theme.placeholder_text()
238        } else {
239            theme.separator()
240        };
241        let stroke = Stroke::new(chevron_stroke, chevron_color);
242
243        let cx = rect.right() - padding_h - chevron_size * 0.5;
244        let cy = rect.center().y;
245        let half_w = chevron_size * 0.5;
246        let delta_y = metrics.chevron_offset_y.points();
247
248        // Up chevron ^
249        let up_tip = Pos2::new(cx, cy - delta_y);
250        let up_left = Pos2::new(cx - half_w, cy);
251        let up_right = Pos2::new(cx + half_w, cy);
252        painter.line_segment([up_left, up_tip], stroke);
253        painter.line_segment([up_tip, up_right], stroke);
254
255        // Down chevron v
256        let down_tip = Pos2::new(cx, cy + delta_y);
257        let down_left = Pos2::new(cx - half_w, cy);
258        let down_right = Pos2::new(cx + half_w, cy);
259        painter.line_segment([down_left, down_tip], stroke);
260        painter.line_segment([down_tip, down_right], stroke);
261
262        // Popup overlay handling
263        let is_open_id = response.id.with("mac_popup_is_open");
264        let mut is_popup_open = ui.data(|d| d.get_temp::<bool>(is_open_id).unwrap_or(false));
265
266        if self.enabled {
267            if response.clicked() {
268                response.request_focus();
269                is_popup_open = !is_popup_open;
270                ui.data_mut(|d| d.insert_temp(is_open_id, is_popup_open));
271            } else if response.has_focus()
272                && (ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Space))
273                    || ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)))
274            {
275                is_popup_open = !is_popup_open;
276                ui.data_mut(|d| d.insert_temp(is_open_id, is_popup_open));
277            }
278        }
279
280        if self.enabled && is_popup_open {
281            let (auto_width, item_galleys) = layout_items(ui, &current_galley, &mut cached_items);
282            let menu_width = desired_width
283                .max(auto_width)
284                .max(metrics.menu_min_width.points());
285            let item_height = height.max(metrics.menu_item_height.points());
286            let menu_padding = metrics.menu_item_padding.points();
287            let total_menu_height = item_height * (self.items.len() as f32) + menu_padding * 2.0;
288
289            let screen_rect = ui
290                .ctx()
291                .input(|i| i.raw.screen_rect)
292                .unwrap_or_else(|| ui.clip_rect());
293            let popup_gap = metrics.popup_gap.points();
294            let popup_y = if rect.bottom() + popup_gap + total_menu_height > screen_rect.bottom()
295                && rect.top() - popup_gap - total_menu_height >= screen_rect.top()
296            {
297                rect.top() - popup_gap - total_menu_height
298            } else {
299                rect.bottom() + popup_gap
300            };
301            let popup_x = if rect.left() + menu_width > screen_rect.right() {
302                (screen_rect.right() - menu_width).max(screen_rect.left())
303            } else {
304                rect.left()
305            };
306            let popup_pos = Pos2::new(popup_x, popup_y);
307            let area_id = response.id.with("mac_popup_area");
308            let mut item_selected_this_frame = false;
309
310            Area::new(area_id)
311                .order(Order::Foreground)
312                .sense(Sense::click())
313                .fixed_pos(popup_pos)
314                .show(ui.ctx(), |menu_ui| {
315                    let (menu_rect, _bg_resp) = menu_ui.allocate_exact_size(
316                        Vec2::new(menu_width, total_menu_height),
317                        Sense::click(),
318                    );
319                    if menu_ui.rect_contains_pointer(menu_rect) {
320                        menu_ui.input_mut(|i| {
321                            i.smooth_scroll_delta = Vec2::ZERO;
322                        });
323                    }
324                    let menu_painter = menu_ui.painter_at(menu_rect);
325
326                    menu_painter.rect(
327                        menu_rect,
328                        CornerRadius::same(corner_radius as u8),
329                        theme.window_background(),
330                        Stroke::new(metrics.border_width.points(), theme.separator()),
331                        StrokeKind::Inside,
332                    );
333
334                    let item_margin = metrics.menu_item_padding.points();
335                    for (idx, item_galley) in item_galleys.into_iter().enumerate() {
336                        let item_rect = Rect::from_min_size(
337                            Pos2::new(
338                                menu_rect.left() + item_margin,
339                                menu_rect.top() + item_margin + (idx as f32) * item_height,
340                            ),
341                            Vec2::new(menu_width - item_margin * 2.0, item_height),
342                        );
343                        let item_resp =
344                            menu_ui.interact(item_rect, area_id.with(idx), Sense::click());
345                        let is_hovered = item_resp.hovered();
346                        let is_active = idx == *self.selected_index;
347
348                        if is_hovered || is_active {
349                            let item_bg = if is_hovered {
350                                theme.control_accent()
351                            } else {
352                                theme.control()
353                            };
354                            menu_painter.rect_filled(
355                                item_rect,
356                                CornerRadius::same(corner_radius as u8),
357                                item_bg,
358                            );
359                        }
360
361                        let item_text_color = if is_hovered {
362                            theme.on_accent_text()
363                        } else {
364                            theme.label()
365                        };
366
367                        let item_text_pos = Align2::LEFT_CENTER
368                            .anchor_size(
369                                Pos2::new(item_rect.left() + menu_padding, item_rect.center().y),
370                                item_galley.size(),
371                            )
372                            .min;
373                        menu_painter.galley(item_text_pos, item_galley, item_text_color);
374
375                        if item_resp.clicked() {
376                            *self.selected_index = idx;
377                            item_selected_this_frame = true;
378                            menu_ui.data_mut(|d| d.insert_temp(is_open_id, false));
379                            response.mark_changed();
380                        }
381                    }
382                });
383
384            if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) {
385                ui.data_mut(|d| d.insert_temp(is_open_id, false));
386            } else if !item_selected_this_frame
387                && ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Primary))
388                && !response.clicked()
389            {
390                let popup_bounds =
391                    Rect::from_min_size(popup_pos, Vec2::new(menu_width, total_menu_height));
392                if !ui.rect_contains_pointer(popup_bounds) {
393                    ui.data_mut(|d| d.insert_temp(is_open_id, false));
394                }
395            }
396        }
397
398        response
399    }
400}