Skip to main content

egui_macview/feedback/
context_menu.rs

1//! Authentic macOS Right-Click Context Menu with Viewport Boundary Clamping.
2//!
3//! Normative Reference: SPEC-008B ยง3.
4//! Enforces mathematical boundary clamping to guarantee zero clipping across screen/window edges.
5
6use 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
14/// Clamps menu origin such that the menu rectangle remains strictly inside the viewport bounds.
15pub 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
35/// Context Menu state and renderer.
36pub struct MacContextMenu<'a> {
37    /// Slice of menu items to display.
38    pub items: &'a [NSMenuItem],
39    /// Mutable reference to menu open state.
40    pub is_open: &'a mut bool,
41    /// Mutable reference to menu screen spawn position.
42    pub spawn_pos: &'a mut Pos2,
43    /// Mutable reference to currently highlighted menu item index.
44    pub selected_idx: &'a mut Option<usize>,
45    /// Optional macOS dynamic theme reference.
46    pub theme: Option<&'a MacTheme>,
47}
48
49impl<'a> MacContextMenu<'a> {
50    /// Creates a new context menu.
51    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    /// Sets the dynamic theme.
67    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
68        self.theme = Some(theme);
69        self
70    }
71
72    /// Checks a widget response (such as on a canvas card or outline row)
73    /// and activates the context menu if secondary-clicked (right-clicked).
74    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    /// Closes the context menu.
85    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    /// Handles keyboard events (Arrow Down, Arrow Up, Enter, Escape).
134    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    /// Renders the context menu if open, returning action ID if an item was executed.
157    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}