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