Skip to main content

egui_macview/controls/combo_box/
popover.rs

1//! Filterable popover overlay and keyboard navigation for [`super::MacComboBox`].
2
3use super::MacComboBox;
4use crate::theme::MacTheme;
5use egui::{
6    Area, CornerRadius, FontId, Id, Key, Modifiers, Order, Pos2, Rect, Response, ScrollArea, Sense,
7    Stroke, StrokeKind, Ui, Vec2,
8};
9
10/// Resolved geometry for the popover dropdown list, clamped to screen boundaries.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct ComboBoxPopoverGeometry {
13    /// Outer popover bounding box.
14    pub popover_rect: Rect,
15    /// Popover top-left screen position.
16    pub popover_pos: Pos2,
17    /// Single row height in points.
18    pub item_height: f32,
19    /// Total popover height in points.
20    pub popover_height: f32,
21    /// Total popover width in points.
22    pub popover_width: f32,
23}
24
25pub(super) fn compute_popover_geometry<T: AsRef<str>>(
26    combo: &MacComboBox<'_, T>,
27    ui: &Ui,
28    header_rect: Rect,
29    item_count: usize,
30) -> ComboBoxPopoverGeometry {
31    let theme_cow = MacTheme::resolve(combo.theme, ui.ctx());
32    let metrics = theme_cow.combo_box();
33    let item_h = metrics.item_height.points();
34    let visible_count = (metrics.visible_items.points() as usize).min(item_count);
35    let pop_h = item_h * (visible_count as f32);
36    let pop_w = header_rect.width();
37
38    let screen_rect = ui
39        .ctx()
40        .input(|i| i.raw.screen_rect)
41        .unwrap_or_else(|| ui.clip_rect());
42
43    let gap = metrics.intercell_spacing_y.points();
44    let pop_y = if header_rect.max.y + gap + pop_h > screen_rect.bottom()
45        && header_rect.min.y - gap - pop_h >= screen_rect.top()
46    {
47        header_rect.min.y - gap - pop_h
48    } else {
49        header_rect.max.y + gap
50    };
51
52    let pop_pos = Pos2::new(header_rect.min.x, pop_y);
53    let pop_rect = Rect::from_min_size(pop_pos, Vec2::new(pop_w, pop_h));
54
55    ComboBoxPopoverGeometry {
56        popover_rect: pop_rect,
57        popover_pos: pop_pos,
58        item_height: item_h,
59        popover_height: pop_h,
60        popover_width: pop_w,
61    }
62}
63
64#[allow(clippy::too_many_arguments)]
65pub(super) fn handle_combo_popover<T: AsRef<str>>(
66    combo: &mut MacComboBox<'_, T>,
67    ui: &mut Ui,
68    theme: &MacTheme,
69    rect: Rect,
70    corner_radius: f32,
71    is_open: &mut bool,
72    stored_sel_idx: Option<usize>,
73    open_id: Id,
74    selected_item_id: Id,
75    pointer_pos: Option<Pos2>,
76    response: &mut Response,
77) {
78    let metrics = theme.combo_box();
79    let mut final_sel_idx = stored_sel_idx.unwrap_or(0);
80
81    if *is_open {
82        let current_text_lower = combo.text.to_lowercase();
83        let is_exact_match = combo
84            .items
85            .iter()
86            .any(|it| it.as_ref().eq_ignore_ascii_case(combo.text.as_str()));
87        let filtered: Vec<(usize, &str)> = combo
88            .items
89            .iter()
90            .enumerate()
91            .map(|(i, item)| (i, item.as_ref()))
92            .filter(|(_, s)| {
93                current_text_lower.is_empty()
94                    || is_exact_match
95                    || s.to_lowercase().contains(&current_text_lower)
96            })
97            .collect();
98
99        let matching_idx = filtered
100            .iter()
101            .position(|(_, s)| s.eq_ignore_ascii_case(combo.text.as_str()))
102            .unwrap_or(0);
103        let mut sel_idx = stored_sel_idx
104            .unwrap_or(matching_idx)
105            .min(filtered.len().saturating_sub(1));
106
107        let mut nav_changed = false;
108        if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) {
109            *is_open = false;
110        } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowDown)) {
111            if !filtered.is_empty() {
112                sel_idx = (sel_idx + 1).min(filtered.len() - 1);
113                nav_changed = true;
114            }
115        } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowUp)) {
116            sel_idx = sel_idx.saturating_sub(1);
117            nav_changed = true;
118        } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) {
119            if let Some((_, selected_str)) = filtered.get(sel_idx) {
120                *combo.text = (*selected_str).to_string();
121                response.mark_changed();
122                *is_open = false;
123            }
124        }
125
126        if *is_open && !filtered.is_empty() {
127            let pop_geom = compute_popover_geometry(combo, ui, rect, filtered.len());
128            let area = Area::new(combo.id_salt.with("popover_area"))
129                .order(Order::Foreground)
130                .fixed_pos(pop_geom.popover_pos);
131
132            area.show(ui.ctx(), |pop_ui| {
133                pop_ui.set_clip_rect(pop_geom.popover_rect);
134                let pop_painter = pop_ui.painter_at(pop_geom.popover_rect);
135                pop_painter.rect(
136                    pop_geom.popover_rect,
137                    CornerRadius::same(corner_radius as u8),
138                    theme.control_background(),
139                    Stroke::new(1.0, theme.separator()),
140                    StrokeKind::Inside,
141                );
142
143                ScrollArea::vertical()
144                    .max_height(pop_geom.popover_height)
145                    .show(pop_ui, |scroll_ui| {
146                        let body_font = scroll_ui
147                            .style()
148                            .text_styles
149                            .get(&egui::TextStyle::Body)
150                            .cloned()
151                            .unwrap_or_else(|| FontId::proportional(theme.text_field().regular.font_size.points()));
152                        for (idx, (_, item_str)) in filtered.iter().enumerate() {
153                            let (row_rect, row_resp) = scroll_ui.allocate_exact_size(
154                                Vec2::new(pop_geom.popover_width, pop_geom.item_height),
155                                Sense::click(),
156                            );
157
158                            let is_selected = idx == sel_idx;
159                            if is_selected && nav_changed {
160                                row_resp.scroll_to_me(Some(egui::Align::Center));
161                            }
162
163                            let row_hovered = row_resp.hovered();
164                            if row_hovered || is_selected {
165                                scroll_ui.painter().rect_filled(
166                                    row_rect,
167                                    CornerRadius::same(0),
168                                    theme.selected_control(),
169                                );
170                            }
171
172                            let text_color = if row_hovered || is_selected {
173                                theme.on_accent_text()
174                            } else {
175                                theme.label()
176                            };
177
178                            let galley = theme.layout_tracked_galley(
179                                scroll_ui,
180                                item_str,
181                                body_font.clone(),
182                                text_color,
183                                f32::INFINITY,
184                            );
185                            scroll_ui.painter().galley(
186                                Pos2::new(
187                                    row_rect.min.x + metrics.intercell_spacing_x.points(),
188                                    row_rect.center().y - galley.size().y * 0.5,
189                                ),
190                                galley,
191                                text_color,
192                            );
193
194                            if row_resp.clicked() {
195                                *combo.text = (*item_str).to_string();
196                                response.mark_changed();
197                                *is_open = false;
198                            }
199                        }
200                    });
201            });
202
203            if ui.input(|i| i.pointer.primary_clicked()) {
204                if let Some(pos) = pointer_pos {
205                    if !rect.contains(pos) && !pop_geom.popover_rect.contains(pos) {
206                        *is_open = false;
207                    }
208                }
209            }
210            final_sel_idx = sel_idx;
211        }
212    }
213
214    ui.data_mut(|d| {
215        d.insert_temp(open_id, *is_open);
216        if *is_open {
217            d.insert_temp(selected_item_id, final_sel_idx);
218        } else {
219            d.remove_temp::<usize>(selected_item_id);
220        }
221    });
222}