1use crate::navigation::menu::{MacMenuItemKind, NSMenuItem};
7use crate::symbols::MacSymbol;
8use crate::theme::MacTheme;
9use egui::{
10 Align2, Area, CornerRadius, FontId, Key, Modifiers, Order, Pos2, Rect, Sense, Stroke,
11 StrokeKind, Ui, Vec2,
12};
13
14pub fn clamp_menu_rect(click_pos: Pos2, menu_size: Vec2, viewport_rect: Rect) -> Rect {
16 let effective_size = Vec2::new(
17 menu_size.x.min(viewport_rect.width()),
18 menu_size.y.min(viewport_rect.height()),
19 );
20 let mut x = click_pos.x;
21 let mut y = click_pos.y;
22
23 if x + effective_size.x > viewport_rect.max.x {
24 x = viewport_rect.max.x - effective_size.x;
25 }
26 if y + effective_size.y > viewport_rect.max.y {
27 y = viewport_rect.max.y - effective_size.y;
28 }
29 x = x.max(viewport_rect.min.x);
30 y = y.max(viewport_rect.min.y);
31
32 Rect::from_min_size(Pos2::new(x, y), effective_size)
33}
34
35pub struct MacContextMenu<'a> {
37 pub items: &'a [NSMenuItem],
39 pub is_open: &'a mut bool,
41 pub spawn_pos: &'a mut Pos2,
43 pub selected_idx: &'a mut Option<usize>,
45 pub theme: Option<&'a MacTheme>,
47}
48
49impl<'a> MacContextMenu<'a> {
50 pub fn new(
52 items: &'a [NSMenuItem],
53 is_open: &'a mut bool,
54 spawn_pos: &'a mut Pos2,
55 selected_idx: &'a mut Option<usize>,
56 ) -> Self {
57 Self {
58 items,
59 is_open,
60 spawn_pos,
61 selected_idx,
62 theme: None,
63 }
64 }
65
66 pub fn theme(mut self, theme: &'a MacTheme) -> Self {
68 self.theme = Some(theme);
69 self
70 }
71
72 pub fn attach_to(&mut self, response: &egui::Response) {
75 if response.secondary_clicked() {
76 *self.is_open = true;
77 *self.spawn_pos = response
78 .interact_pointer_pos()
79 .unwrap_or_else(|| response.rect.left_top());
80 *self.selected_idx = None;
81 }
82 }
83
84 pub fn close(&mut self) {
86 *self.is_open = false;
87 *self.selected_idx = None;
88 }
89
90 fn step_selection(&mut self, forward: bool) {
91 let items_len = self.items.len();
92 if items_len == 0 {
93 return;
94 }
95 let start = if forward {
96 self.selected_idx.map_or(0, |idx| (idx + 1) % items_len)
97 } else {
98 self.selected_idx
99 .map_or(items_len.saturating_sub(1), |idx| {
100 (idx + items_len - 1) % items_len
101 })
102 };
103 for step in 0..items_len {
104 let candidate = if forward {
105 (start + step) % items_len
106 } else {
107 (start + items_len - (step % items_len)) % items_len
108 };
109 if let Some(item) = self.items.get(candidate) {
110 if item.enabled && !matches!(item.kind, MacMenuItemKind::Separator) {
111 *self.selected_idx = Some(candidate);
112 break;
113 }
114 }
115 }
116 }
117
118 fn activate_selected(&mut self) -> Option<String> {
119 if let Some(idx) = *self.selected_idx {
120 if let Some(item) = self.items.get(idx) {
121 if item.enabled {
122 if let MacMenuItemKind::Action { id, .. } = &item.kind {
123 let action = id.clone();
124 self.close();
125 return Some(action);
126 }
127 }
128 }
129 }
130 None
131 }
132
133 pub fn handle_input(&mut self, input: &egui::InputState) -> Option<String> {
135 if !*self.is_open {
136 return None;
137 }
138 if input.key_pressed(Key::Escape) {
139 self.close();
140 return None;
141 }
142 if input.key_pressed(Key::ArrowDown) {
143 self.step_selection(true);
144 return None;
145 }
146 if input.key_pressed(Key::ArrowUp) {
147 self.step_selection(false);
148 return None;
149 }
150 if input.key_pressed(Key::Enter) || input.key_pressed(Key::Space) {
151 return self.activate_selected();
152 }
153 None
154 }
155
156 pub fn show(mut self, ui: &mut Ui) -> Option<String> {
158 if !*self.is_open {
159 return None;
160 }
161
162 let fallback_theme;
163 let theme = match self.theme {
164 Some(t) => t,
165 None => {
166 fallback_theme = MacTheme::from_ctx(ui.ctx());
167 &fallback_theme
168 }
169 };
170
171 let key_action = ui.input_mut(|i| {
172 if i.consume_key(Modifiers::NONE, Key::Escape) {
173 self.close();
174 return None;
175 }
176 if i.consume_key(Modifiers::NONE, Key::ArrowDown) {
177 self.step_selection(true);
178 return None;
179 }
180 if i.consume_key(Modifiers::NONE, Key::ArrowUp) {
181 self.step_selection(false);
182 return None;
183 }
184 if i.consume_key(Modifiers::NONE, Key::Enter)
185 || i.consume_key(Modifiers::NONE, Key::Space)
186 {
187 return self.activate_selected();
188 }
189 None
190 });
191 if let Some(action) = key_action {
192 return Some(action);
193 }
194 if !*self.is_open {
195 return None;
196 }
197
198 let cm = theme.context_menu_metrics();
199 let item_height = cm.item_height.points();
200 let padding = cm.padding.points();
201 let padding_h = cm.item_padding_x.points();
202 let min_width = cm.min_width.points();
203 let corner_r = cm.corner_radius.points() as u8;
204 let item_corner_r = cm.row_corner_radius.points() as u8;
205 let border_w = theme.popover_metrics().border_width.points();
206 let min_clearance = cm.shortcut_clearance.points();
207 let font_id = FontId::proportional(cm.font_size.points());
208 let on_accent = theme.on_accent_text();
209 let has_any_checked = self.items.iter().any(|it| it.checked);
210 let check_col_w = padding_h * 2.0;
211 let lead_offset = if has_any_checked {
212 check_col_w
213 } else {
214 padding_h
215 };
216
217 let mut max_item_w = 0.0f32;
218 for item in self.items {
219 if matches!(item.kind, MacMenuItemKind::Separator) {
220 continue;
221 }
222 let title_w = ui
223 .painter()
224 .layout_no_wrap(item.title.clone(), font_id.clone(), on_accent)
225 .size()
226 .x;
227 let sc_w = match &item.kind {
228 MacMenuItemKind::Action {
229 shortcut: Some(sc), ..
230 } => {
231 ui.painter()
232 .layout_no_wrap(sc.format_text(), font_id.clone(), on_accent)
233 .size()
234 .x
235 }
236 _ => 0.0,
237 };
238 let item_w = if sc_w > 0.0 {
239 lead_offset + padding_h + title_w + min_clearance + sc_w
240 } else {
241 lead_offset + padding_h + title_w
242 };
243 max_item_w = max_item_w.max(item_w);
244 }
245 let menu_width = min_width.max(max_item_w + padding * 2.0);
246 let total_h = (self.items.len() as f32) * item_height + padding * 2.0;
247
248 let viewport_rect = ui
249 .ctx()
250 .input(|i| i.raw.screen_rect)
251 .unwrap_or_else(|| ui.clip_rect());
252 let menu_rect = clamp_menu_rect(
253 *self.spawn_pos,
254 Vec2::new(menu_width, total_h),
255 viewport_rect,
256 );
257
258 let clicked_outside = ui.input(|i| {
259 for ev in &i.events {
260 if let egui::Event::PointerButton {
261 pos, pressed: true, ..
262 } = ev
263 {
264 if !menu_rect.contains(*pos) {
265 return true;
266 }
267 }
268 }
269 false
270 });
271 if clicked_outside {
272 self.close();
273 return None;
274 }
275
276 let mut triggered = None;
277 let area_id = ui.scope_id().with("mac_context_menu_area");
278
279 Area::new(area_id)
280 .order(Order::Foreground)
281 .fixed_pos(menu_rect.min)
282 .show(ui.ctx(), |menu_ui| {
283 let _ = menu_ui.allocate_rect(menu_rect, Sense::click());
284 let painter = menu_ui.painter();
285
286 painter.rect(
287 menu_rect,
288 CornerRadius::same(corner_r),
289 theme.window_background(),
290 Stroke::new(border_w, theme.separator()),
291 StrokeKind::Inside,
292 );
293
294 for (idx, item) in self.items.iter().enumerate() {
295 let item_y = menu_rect.top() + padding + (idx as f32) * item_height;
296 let item_rect = Rect::from_min_size(
297 Pos2::new(menu_rect.left() + padding, item_y),
298 Vec2::new(menu_rect.width() - padding * 2.0, item_height),
299 );
300
301 if matches!(item.kind, MacMenuItemKind::Separator) {
302 painter.line_segment(
303 [
304 Pos2::new(item_rect.left(), item_rect.center().y),
305 Pos2::new(item_rect.right(), item_rect.center().y),
306 ],
307 Stroke::new(border_w, theme.separator()),
308 );
309 continue;
310 }
311
312 let is_highlighted = *self.selected_idx == Some(idx);
313 let resp = menu_ui.interact(item_rect, area_id.with(idx), Sense::click());
314
315 if resp.hovered() && item.enabled {
316 *self.selected_idx = Some(idx);
317 }
318
319 if is_highlighted && item.enabled {
320 painter.rect(
321 item_rect,
322 CornerRadius::same(item_corner_r),
323 theme.control_accent(),
324 Stroke::NONE,
325 StrokeKind::Inside,
326 );
327 }
328
329 let fg = if !item.enabled {
330 theme.placeholder_text()
331 } else if is_highlighted {
332 on_accent
333 } else {
334 theme.label()
335 };
336
337 if item.checked {
338 painter.text(
339 Pos2::new(item_rect.left() + lead_offset * 0.5, item_rect.center().y),
340 Align2::CENTER_CENTER,
341 MacSymbol::Checkmark.as_str(),
342 font_id.clone(),
343 fg,
344 );
345 }
346
347 painter.text(
348 Pos2::new(item_rect.left() + lead_offset, item_rect.center().y),
349 Align2::LEFT_CENTER,
350 &item.title,
351 font_id.clone(),
352 fg,
353 );
354
355 if let MacMenuItemKind::Action {
356 shortcut: Some(sc), ..
357 } = &item.kind
358 {
359 painter.text(
360 Pos2::new(item_rect.right() - padding_h, item_rect.center().y),
361 Align2::RIGHT_CENTER,
362 sc.format_text(),
363 font_id.clone(),
364 fg,
365 );
366 }
367
368 if resp.clicked() && item.enabled {
369 if let MacMenuItemKind::Action { id, .. } = &item.kind {
370 triggered = Some(id.clone());
371 self.close();
372 }
373 }
374 }
375 });
376
377 triggered
378 }
379}