1use 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
13pub 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 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 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 pub fn theme(mut self, theme: &'a MacTheme) -> Self {
69 self.theme = Some(theme);
70 self
71 }
72
73 pub fn width(mut self, width: f32) -> Self {
75 self.width = Some(width);
76 self
77 }
78
79 pub fn height(mut self, height: f32) -> Self {
81 self.height = Some(height);
82 self
83 }
84
85 pub fn corner_radius(mut self, r: f32) -> Self {
87 self.corner_radius = Some(r);
88 self
89 }
90
91 pub fn enabled(mut self, enabled: bool) -> Self {
93 self.enabled = enabled;
94 self
95 }
96
97 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, ¤t_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 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 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 if self.enabled && response.has_focus() {
208 theme.paint_focus_ring(&painter, rect, corner_radius);
209 }
210
211 painter.rect(
213 rect,
214 CornerRadius::same(corner_radius as u8),
215 bg_color,
216 border_stroke,
217 StrokeKind::Inside,
218 );
219
220 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 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 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 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 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, ¤t_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}