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> {
62 pub items: &'a [NSMenuItem],
64 pub is_open: &'a mut bool,
66 pub spawn_pos: &'a mut Pos2,
68 pub selected_idx: &'a mut Option<usize>,
70 pub theme: Option<&'a MacTheme>,
72}
73
74impl<'a> MacContextMenu<'a> {
75 pub fn new(
77 items: &'a [NSMenuItem],
78 is_open: &'a mut bool,
79 spawn_pos: &'a mut Pos2,
80 selected_idx: &'a mut Option<usize>,
81 ) -> Self {
82 Self {
83 items,
84 is_open,
85 spawn_pos,
86 selected_idx,
87 theme: None,
88 }
89 }
90
91 pub fn theme(mut self, theme: &'a MacTheme) -> Self {
93 self.theme = Some(theme);
94 self
95 }
96
97 pub fn attach_to(&mut self, response: &egui::Response) {
100 if response.secondary_clicked() {
101 *self.is_open = true;
102 *self.spawn_pos = response
103 .interact_pointer_pos()
104 .unwrap_or_else(|| response.rect.left_top());
105 *self.selected_idx = None;
106 }
107 }
108
109 pub fn close(&mut self) {
111 *self.is_open = false;
112 *self.selected_idx = None;
113 }
114
115 fn step_selection(&mut self, forward: bool) {
116 if let Some(candidate) = crate::navigation::submenu::step_enabled_menu_item(
117 self.items,
118 *self.selected_idx,
119 forward,
120 ) {
121 *self.selected_idx = Some(candidate);
122 }
123 }
124
125 fn activate_selected(&mut self) -> Option<String> {
126 if let Some(idx) = *self.selected_idx {
127 if let Some(item) = self.items.get(idx) {
128 if item.enabled {
129 if let MacMenuItemKind::Action { id, .. } = &item.kind {
130 let action = id.clone();
131 self.close();
132 return Some(action);
133 }
134 }
135 }
136 }
137 None
138 }
139
140 pub fn handle_input(&mut self, input: &egui::InputState) -> Option<String> {
142 if !*self.is_open {
143 return None;
144 }
145 if input.key_pressed(Key::Escape) {
146 self.close();
147 return None;
148 }
149 if input.key_pressed(Key::ArrowDown) {
150 self.step_selection(true);
151 return None;
152 }
153 if input.key_pressed(Key::ArrowUp) {
154 self.step_selection(false);
155 return None;
156 }
157 if input.key_pressed(Key::Enter) || input.key_pressed(Key::Space) {
158 return self.activate_selected();
159 }
160 None
161 }
162
163 pub fn show(mut self, ui: &mut Ui) -> Option<String> {
165 if !*self.is_open {
166 return None;
167 }
168
169 let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
170 let theme: &MacTheme = &theme_cow;
171
172 let key_action = ui.input_mut(|i| {
173 if i.consume_key(Modifiers::NONE, Key::Escape) {
174 self.close();
175 return None;
176 }
177 if i.consume_key(Modifiers::NONE, Key::ArrowDown) {
178 self.step_selection(true);
179 return None;
180 }
181 if i.consume_key(Modifiers::NONE, Key::ArrowUp) {
182 self.step_selection(false);
183 return None;
184 }
185 if i.consume_key(Modifiers::NONE, Key::Enter)
186 || i.consume_key(Modifiers::NONE, Key::Space)
187 {
188 return self.activate_selected();
189 }
190 None
191 });
192 if let Some(action) = key_action {
193 return Some(action);
194 }
195 if !*self.is_open {
196 return None;
197 }
198
199 let cm = theme.context_menu_metrics();
200 let item_height = cm.item_height.points();
201 let padding = cm.padding.points();
202 let padding_h = cm.item_padding_x.points();
203 let min_width = cm.min_width.points();
204 let corner_r = cm.corner_radius.points() as u8;
205 let item_corner_r = cm.row_corner_radius.points() as u8;
206 let border_w = theme.popover_metrics().border_width.points();
207 let min_clearance = cm.shortcut_clearance.points();
208 let font_id = FontId::proportional(cm.font_size.points());
209 let on_accent = theme.on_accent_text();
210 let has_any_checked = self.items.iter().any(|it| it.checked);
211 let check_col_w = padding_h * 2.0;
212 let lead_offset = if has_any_checked {
213 check_col_w
214 } else {
215 padding_h
216 };
217
218 let (max_item_w, item_galleys) = crate::navigation::submenu::layout_menu_items(
219 ui.painter(),
220 self.items,
221 &font_id,
222 lead_offset,
223 padding_h,
224 min_clearance,
225 );
226 let menu_width = min_width.max(max_item_w + padding * 2.0);
227 let total_h = (self.items.len() as f32) * item_height + padding * 2.0;
228
229 let viewport_rect = ui
230 .ctx()
231 .input(|i| i.raw.screen_rect)
232 .unwrap_or_else(|| ui.clip_rect());
233 let menu_rect = clamp_menu_rect(
234 *self.spawn_pos,
235 Vec2::new(menu_width, total_h),
236 viewport_rect,
237 );
238
239 let clicked_outside = ui.input(|i| {
240 for ev in &i.events {
241 if let egui::Event::PointerButton {
242 pos, pressed: true, ..
243 } = ev
244 {
245 if !menu_rect.contains(*pos) {
246 return true;
247 }
248 }
249 }
250 false
251 });
252 if clicked_outside {
253 self.close();
254 return None;
255 }
256
257 let mut triggered = None;
258 let area_id = ui.scope_id().with("mac_context_menu_area");
259
260 Area::new(area_id)
261 .order(Order::Foreground)
262 .fixed_pos(menu_rect.min)
263 .show(ui.ctx(), |menu_ui| {
264 let _ = menu_ui.allocate_rect(menu_rect, Sense::click());
265 let painter = menu_ui.painter();
266
267 painter.rect(
268 menu_rect,
269 CornerRadius::same(corner_r),
270 theme.window_background(),
271 Stroke::new(border_w, theme.separator()),
272 StrokeKind::Inside,
273 );
274
275 for ((idx, item), galleys) in self.items.iter().enumerate().zip(item_galleys) {
276 let item_y = menu_rect.top() + padding + (idx as f32) * item_height;
277 let item_rect = Rect::from_min_size(
278 Pos2::new(menu_rect.left() + padding, item_y),
279 Vec2::new(menu_rect.width() - padding * 2.0, item_height),
280 );
281
282 let Some((title_galley, trailing_galley)) = galleys else {
283 painter.line_segment(
284 [
285 Pos2::new(item_rect.left(), item_rect.center().y),
286 Pos2::new(item_rect.right(), item_rect.center().y),
287 ],
288 Stroke::new(border_w, theme.separator()),
289 );
290 continue;
291 };
292
293 let is_highlighted = *self.selected_idx == Some(idx);
294 let resp = menu_ui.interact(item_rect, area_id.with(idx), Sense::click());
295
296 if resp.hovered() && item.enabled {
297 *self.selected_idx = Some(idx);
298 }
299
300 if is_highlighted && item.enabled {
301 painter.rect(
302 item_rect,
303 CornerRadius::same(item_corner_r),
304 theme.control_accent(),
305 Stroke::NONE,
306 StrokeKind::Inside,
307 );
308 }
309
310 let fg = if !item.enabled {
311 theme.placeholder_text()
312 } else if is_highlighted {
313 on_accent
314 } else {
315 theme.label()
316 };
317
318 if item.checked {
319 painter.text(
320 Pos2::new(item_rect.left() + lead_offset * 0.5, item_rect.center().y),
321 Align2::CENTER_CENTER,
322 MacSymbol::Checkmark.as_str(),
323 font_id.clone(),
324 fg,
325 );
326 }
327
328 let title_pos = Align2::LEFT_CENTER
329 .anchor_size(
330 Pos2::new(item_rect.left() + lead_offset, item_rect.center().y),
331 title_galley.size(),
332 )
333 .min;
334 painter.galley(title_pos, title_galley, fg);
335
336 if let Some(tg) = trailing_galley {
337 let tr_pos = Align2::RIGHT_CENTER
338 .anchor_size(
339 Pos2::new(item_rect.right() - padding_h, item_rect.center().y),
340 tg.size(),
341 )
342 .min;
343 painter.galley(tr_pos, tg, fg);
344 }
345
346 if resp.clicked() && item.enabled {
347 if let MacMenuItemKind::Action { id, .. } = &item.kind {
348 triggered = Some(id.clone());
349 self.close();
350 }
351 }
352 }
353 });
354
355 triggered
356 }
357}