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-008 §3.
4//! Enforces mathematical boundary clamping to guarantee zero clipping across screen/window edges.
5
6use crate::navigation::menu::NSMenuItem;
7use crate::theme::MacTheme;
8use egui::{
9    Align2, Area, Color32, CornerRadius, FontId, Key, Order, Pos2, Rect, Sense, Stroke, StrokeKind,
10    Ui, Vec2,
11};
12
13/// Clamps menu origin such that the menu rectangle remains strictly inside the viewport bounds.
14pub fn clamp_menu_rect(click_pos: Pos2, menu_size: Vec2, viewport_rect: Rect) -> Rect {
15    let effective_size = Vec2::new(
16        menu_size.x.min(viewport_rect.width()),
17        menu_size.y.min(viewport_rect.height()),
18    );
19    let mut x = click_pos.x;
20    let mut y = click_pos.y;
21
22    // Clamp right edge
23    if x + effective_size.x > viewport_rect.max.x {
24        x = viewport_rect.max.x - effective_size.x;
25    }
26    // Clamp bottom edge
27    if y + effective_size.y > viewport_rect.max.y {
28        y = viewport_rect.max.y - effective_size.y;
29    }
30    // Clamp left and top edges
31    x = x.max(viewport_rect.min.x);
32    y = y.max(viewport_rect.min.y);
33
34    Rect::from_min_size(Pos2::new(x, y), effective_size)
35}
36
37/// Context Menu state and renderer.
38pub struct MacContextMenu<'a> {
39    /// Slice of menu items to display.
40    pub items: &'a [NSMenuItem],
41    /// Mutable reference to menu open state.
42    pub is_open: &'a mut bool,
43    /// Mutable reference to menu screen spawn position.
44    pub spawn_pos: &'a mut Pos2,
45    /// Mutable reference to currently highlighted menu item index.
46    pub selected_idx: &'a mut Option<usize>,
47    /// Optional macOS dynamic theme reference.
48    pub theme: Option<&'a MacTheme>,
49}
50
51impl<'a> MacContextMenu<'a> {
52    /// Creates a new context menu.
53    pub fn new(
54        items: &'a [NSMenuItem],
55        is_open: &'a mut bool,
56        spawn_pos: &'a mut Pos2,
57        selected_idx: &'a mut Option<usize>,
58    ) -> Self {
59        Self {
60            items,
61            is_open,
62            spawn_pos,
63            selected_idx,
64            theme: None,
65        }
66    }
67
68    /// Sets the dynamic theme.
69    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
70        self.theme = Some(theme);
71        self
72    }
73
74    /// Checks a widget response (such as on a canvas card or outline row)
75    /// and activates the context menu if secondary-clicked (right-clicked).
76    pub fn attach_to(&mut self, response: &egui::Response) {
77        if response.secondary_clicked() {
78            *self.is_open = true;
79            *self.spawn_pos = response
80                .interact_pointer_pos()
81                .unwrap_or_else(|| response.rect.left_top());
82            *self.selected_idx = None;
83        }
84    }
85
86    /// Closes the context menu.
87    pub fn close(&mut self) {
88        *self.is_open = false;
89        *self.selected_idx = None;
90    }
91
92    /// Handles keyboard events (Arrow Down, Arrow Up, Enter, Escape).
93    pub fn handle_input(&mut self, input: &egui::InputState) -> Option<String> {
94        if !*self.is_open {
95            return None;
96        }
97
98        if input.key_pressed(Key::Escape) {
99            self.close();
100            return None;
101        }
102
103        let items_len = self.items.len();
104        if items_len == 0 {
105            return None;
106        }
107
108        if input.key_pressed(Key::ArrowDown) {
109            let start = self.selected_idx.map_or(0, |idx| (idx + 1) % items_len);
110            for step in 0..items_len {
111                let candidate = (start + step) % items_len;
112                if let Some(item) = self.items.get(candidate) {
113                    if item.enabled
114                        && !matches!(
115                            item.kind,
116                            crate::navigation::menu::MacMenuItemKind::Separator
117                        )
118                    {
119                        *self.selected_idx = Some(candidate);
120                        break;
121                    }
122                }
123            }
124            return None;
125        }
126
127        if input.key_pressed(Key::ArrowUp) {
128            let start = self
129                .selected_idx
130                .map_or(items_len.saturating_sub(1), |idx| {
131                    (idx + items_len - 1) % items_len
132                });
133            for step in 0..items_len {
134                let candidate = (start + items_len - (step % items_len)) % items_len;
135                if let Some(item) = self.items.get(candidate) {
136                    if item.enabled
137                        && !matches!(
138                            item.kind,
139                            crate::navigation::menu::MacMenuItemKind::Separator
140                        )
141                    {
142                        *self.selected_idx = Some(candidate);
143                        break;
144                    }
145                }
146            }
147            return None;
148        }
149
150        if input.key_pressed(Key::Enter) || input.key_pressed(Key::Space) {
151            if let Some(idx) = *self.selected_idx {
152                if let Some(item) = self.items.get(idx) {
153                    if item.enabled {
154                        if let crate::navigation::menu::MacMenuItemKind::Action { id, .. } =
155                            &item.kind
156                        {
157                            let action = id.clone();
158                            self.close();
159                            return Some(action);
160                        }
161                    }
162                }
163            }
164        }
165
166        None
167    }
168
169    /// Renders the context menu if open, returning action ID if an item was executed.
170    pub fn show(mut self, ui: &mut Ui) -> Option<String> {
171        if !*self.is_open {
172            return None;
173        }
174
175        let fallback_theme;
176        let theme = match self.theme {
177            Some(t) => t,
178            None => {
179                fallback_theme = MacTheme::default();
180                &fallback_theme
181            }
182        };
183
184        if let Some(action) = ui.input(|i| self.handle_input(i)) {
185            return Some(action);
186        }
187
188        let item_height = 22.0f32;
189        let padding = 4.0f32;
190        let min_width = 140.0f32;
191        let total_h = (self.items.len() as f32) * item_height + padding * 2.0;
192
193        let viewport_rect = ui
194            .ctx()
195            .input(|i| i.raw.screen_rect)
196            .unwrap_or_else(|| ui.clip_rect());
197        let menu_rect = clamp_menu_rect(
198            *self.spawn_pos,
199            Vec2::new(min_width, total_h),
200            viewport_rect,
201        );
202
203        // Clicking outside context menu bounds dismisses menu without action (SPEC-008 §3.2)
204        let clicked_outside = ui.input(|i| {
205            for ev in &i.events {
206                if let egui::Event::PointerButton { pos, pressed: true, .. } = ev {
207                    if !menu_rect.contains(*pos) {
208                        return true;
209                    }
210                }
211            }
212            false
213        });
214        if clicked_outside {
215            self.close();
216            return None;
217        }
218
219        let mut triggered = None;
220        let area_id = ui.scope_id().with("mac_context_menu_area");
221
222        Area::new(area_id)
223            .order(Order::Foreground)
224            .fixed_pos(menu_rect.min)
225            .show(ui.ctx(), |menu_ui| {
226                let painter = menu_ui.painter();
227                let corner_r = 6u8;
228
229                painter.rect(
230                    menu_rect,
231                    CornerRadius::same(corner_r),
232                    theme.window_background(),
233                    Stroke::new(1.0, theme.separator()),
234                    StrokeKind::Inside,
235                );
236
237                let font_id = FontId::proportional(12.0);
238
239                for (idx, item) in self.items.iter().enumerate() {
240                    let item_y = menu_rect.top() + padding + (idx as f32) * item_height;
241                    let item_rect = Rect::from_min_size(
242                        Pos2::new(menu_rect.left() + padding, item_y),
243                        Vec2::new(menu_rect.width() - padding * 2.0, item_height),
244                    );
245
246                    if matches!(
247                        item.kind,
248                        crate::navigation::menu::MacMenuItemKind::Separator
249                    ) {
250                        painter.line_segment(
251                            [
252                                Pos2::new(item_rect.left(), item_rect.center().y),
253                                Pos2::new(item_rect.right(), item_rect.center().y),
254                            ],
255                            Stroke::new(1.0, theme.separator()),
256                        );
257                        continue;
258                    }
259
260                    let is_highlighted = *self.selected_idx == Some(idx);
261                    let resp = menu_ui.interact(item_rect, area_id.with(idx), Sense::click());
262
263                    if resp.hovered() && item.enabled {
264                        *self.selected_idx = Some(idx);
265                    }
266
267                    if is_highlighted && item.enabled {
268                        painter.rect(
269                            item_rect,
270                            CornerRadius::same(4),
271                            theme.control_accent(),
272                            Stroke::NONE,
273                            StrokeKind::Inside,
274                        );
275                    }
276
277                    let fg = if !item.enabled {
278                        theme.placeholder_text()
279                    } else if is_highlighted {
280                        Color32::WHITE
281                    } else {
282                        theme.label()
283                    };
284
285                    painter.text(
286                        Pos2::new(item_rect.left() + 8.0, item_rect.center().y),
287                        Align2::LEFT_CENTER,
288                        &item.title,
289                        font_id.clone(),
290                        fg,
291                    );
292
293                    if let crate::navigation::menu::MacMenuItemKind::Action {
294                        shortcut: Some(sc),
295                        ..
296                    } = &item.kind
297                    {
298                        painter.text(
299                            Pos2::new(item_rect.right() - 8.0, item_rect.center().y),
300                            Align2::RIGHT_CENTER,
301                            sc.format_text(),
302                            font_id.clone(),
303                            fg,
304                        );
305                    }
306
307                    if resp.clicked() && item.enabled {
308                        if let crate::navigation::menu::MacMenuItemKind::Action { id, .. } =
309                            &item.kind
310                        {
311                            triggered = Some(id.clone());
312                            self.close();
313                        }
314                    }
315                }
316            });
317
318        triggered
319    }
320}