1mod popover;
4
5pub use popover::ComboBoxPopoverGeometry;
6
7use crate::controls::MacControlSize;
8use crate::layout::{subpixel_snap, 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#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct ComboBoxHeaderLayout {
19 pub total_rect: Rect,
21 pub button_rect: Rect,
23 pub text_rect: Rect,
25 pub corner_radius: f32,
27}
28
29pub 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 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 pub fn control_size(mut self, size: MacControlSize) -> Self {
69 self.control_size = size;
70 self
71 }
72
73 pub fn theme(mut self, theme: &'a MacTheme) -> Self {
75 self.theme = Some(theme);
76 self
77 }
78
79 pub fn width(mut self, width: f32) -> Self {
81 self.width = Some(width);
82 self
83 }
84
85 pub fn fixed_rect(mut self, rect: Rect) -> Self {
87 self.fixed_rect = Some(rect);
88 self
89 }
90
91 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 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 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 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 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 pub fn item_height(&self) -> f32 {
148 MacTheme::resolve_or_default(self.theme).combo_box().item_height.points()
149 }
150
151 pub fn visible_items(&self) -> usize {
153 MacTheme::resolve_or_default(self.theme).combo_box().visible_items.points() as usize
154 }
155
156 pub fn popover_viewport_height(&self) -> f32 {
158 self.item_height() * (self.visible_items() as f32)
159 }
160
161 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 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 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 ppp = ui.ctx().pixels_per_point();
189 let (frame_rect, visual_rect, mut response) = if let Some(fixed) = self.fixed_rect {
190 let sf = subpixel_snap_rect(fixed, ppp);
191 let resp = ui.interact(sf, self.id_salt.with("fixed_combo"), Sense::click());
192 let bh = fixed.height().min(self.visual_height());
193 (sf, subpixel_snap_rect(Rect::from_center_size(sf.center(), Vec2::new(sf.width(), bh)), ppp), resp)
194 } else {
195 let (r, resp) = ui.allocate_exact_size(layout.total_rect.size(), Sense::click());
196 let snapped = subpixel_snap_rect(r, ppp);
197 (snapped, snapped, resp)
198 };
199
200 let open_id = self.id_salt.with("popover_open");
201 let mut is_open = ui.data_mut(|d| d.get_temp::<bool>(open_id).unwrap_or(false));
202 let selected_item_id = self.id_salt.with("popover_sel_idx");
203 let stored_sel_idx = ui.data_mut(|d| d.get_temp::<usize>(selected_item_id));
204
205 let (btn_w, text_inset) = (self.button_width(), self.text_inset());
206 let btn_rect = Rect::from_min_max(Pos2::new(visual_rect.max.x - btn_w, visual_rect.min.y), visual_rect.max);
207 let text_rect = Rect::from_min_max(
208 Pos2::new(visual_rect.min.x + text_inset, visual_rect.min.y),
209 Pos2::new(visual_rect.max.x - btn_w, visual_rect.max.y),
210 );
211
212 if ui.input_mut(|i| i.consume_key(Modifiers::ALT, Key::ArrowDown))
213 || ui.input(|i| i.modifiers.alt && i.key_pressed(Key::ArrowDown))
214 {
215 is_open = true;
216 }
217
218 let painter = ui.painter_at(frame_rect);
219 let border_col = if response.has_focus() { theme.control_accent() } else { theme.separator() };
220 painter.rect(visual_rect, CornerRadius::same(layout.corner_radius as u8), theme.control_background(), Stroke::new(1.0, border_col), StrokeKind::Inside);
221 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.separator()));
222
223 let pointer_pos = ui.input(|i| i.pointer.hover_pos());
224 let btn_hovered = pointer_pos.is_some_and(|p| btn_rect.contains(p));
225 let btn_down = btn_hovered && ui.input(|i| i.pointer.primary_down());
226 let btn_radius = CornerRadius { nw: 0, sw: 0, ne: layout.corner_radius as u8, se: layout.corner_radius as u8 };
227 if btn_down {
228 painter.rect_filled(btn_rect, btn_radius, theme.selected_control());
229 } else if btn_hovered {
230 painter.rect_filled(btn_rect, btn_radius, theme.control());
231 }
232 if btn_hovered && ui.input(|i| i.pointer.primary_clicked()) {
233 is_open = !is_open;
234 }
235
236 let (badge_w, badge_inset, badge_r) = (metrics.badge_button_width.points(), metrics.button_inset.points(), metrics.button_corner_radius.points());
238 let badge_rect = Rect::from_min_max(
239 Pos2::new((visual_rect.max.x - badge_inset - badge_w).max(btn_rect.min.x), visual_rect.min.y + badge_inset),
240 Pos2::new(visual_rect.max.x - badge_inset, visual_rect.max.y - badge_inset),
241 );
242 let badge_fill = if btn_down { theme.selected_control() } else { theme.control_accent() };
243 painter.rect_filled(badge_rect, CornerRadius::same(badge_r as u8), badge_fill);
244
245 let (cx, cy) = (badge_rect.center().x, badge_rect.center().y);
246 let (ch_hw, ch_hh) = (metrics.chevron_half_width.points(), metrics.chevron_half_height.points());
247 let ch_stroke = Stroke::new(metrics.chevron_stroke_width.points(), theme.on_accent_text());
248 painter.line_segment([Pos2::new(cx - ch_hw, cy - ch_hh * 0.5), Pos2::new(cx, cy + ch_hh)], ch_stroke);
249 painter.line_segment([Pos2::new(cx, cy + ch_hh), Pos2::new(cx + ch_hw, cy - ch_hh * 0.5)], ch_stroke);
250
251 let mut child_ui = ui.new_child(UiBuilder::new().id_salt(self.id_salt.with("text_edit")).max_rect(text_rect));
252 let font_sz = match self.control_size {
253 MacControlSize::Regular | MacControlSize::Large => theme.text_field().regular.font_size.points(),
254 MacControlSize::Small | MacControlSize::CompactInfobox => theme.text_field().small.font_size.points(),
255 MacControlSize::Mini => theme.text_field().mini.font_size.points(),
256 };
257 let edit_id = self.id_salt.with("combo_input_id");
258 let is_edit_focused = ui.memory(|m| m.has_focus(edit_id));
259 if is_edit_focused {
260 let edit_res = child_ui.add_sized(
261 text_rect.size(),
262 TextEdit::singleline(self.text).id(edit_id).font(FontId::proportional(font_sz)).text_color(theme.label()).frame(egui::Frame::NONE),
263 );
264 if edit_res.changed() {
265 response.mark_changed();
266 is_open = true;
267 }
268 } else {
269 let click_res = child_ui.interact(text_rect, edit_id, Sense::click());
270 if click_res.clicked() {
271 ui.memory_mut(|m| m.request_focus(edit_id));
272 }
273 if !self.text.is_empty() {
274 let lm = theme.label_metrics();
275 let (baseline_from_top, ascender) = match self.control_size {
276 MacControlSize::Regular | MacControlSize::Large => (metrics.regular_first_baseline_offset.points(), lm.regular_ascender.points()),
277 MacControlSize::Small | MacControlSize::CompactInfobox => (metrics.small_first_baseline_offset.points(), lm.small_ascender.points()),
278 MacControlSize::Mini => (metrics.mini_first_baseline_offset.points(), lm.mini_ascender.points()),
279 };
280 let galley = theme.layout_tracked_galley(ui, self.text.as_str(), FontId::proportional(font_sz), theme.label(), text_rect.width().max(0.0));
281 let text_y = subpixel_snap(visual_rect.top() + (baseline_from_top - ascender), ppp);
282 painter.with_clip_rect(text_rect).galley(Pos2::new(text_rect.left(), text_y), galley, theme.label());
283 }
284 }
285
286 popover::handle_combo_popover(
287 &mut self, ui, theme, visual_rect, layout.corner_radius, &mut is_open,
288 stored_sel_idx, open_id, selected_item_id, pointer_pos, &mut response,
289 );
290 response
291 }
292}