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        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    /// Handles keyboard events (Arrow Down, Arrow Up, Enter, Escape).
116    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    /// Renders the context menu if open, returning action ID if an item was executed.
139    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}