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.
36///
37/// # Example
38/// ```rust
39/// use egui_macview::{MacAppearance, MacContextMenu, MacTheme, NSMenuItem};
40///
41/// # let ctx = egui::Context::default();
42/// # let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
43/// let theme = MacTheme::new(MacAppearance::Aqua);
44/// let items = [
45///     NSMenuItem::action("duplicate", "Duplicate"),
46///     NSMenuItem::separator(),
47///     NSMenuItem::action("delete", "Delete"),
48/// ];
49/// let mut is_open = false;
50/// let mut spawn_pos = egui::Pos2::ZERO;
51/// let mut selected_idx = None;
52///
53/// let label_resp = ui.label("Right-click me");
54/// let mut menu = MacContextMenu::new(&items, &mut is_open, &mut spawn_pos, &mut selected_idx)
55///     .theme(&theme);
56/// menu.attach_to(&label_resp);
57/// menu.show(ui);
58/// # });
59/// # out.textures_delta.clear();
60/// ```
61pub struct MacContextMenu<'a> {
62    /// Slice of menu items to display.
63    pub items: &'a [NSMenuItem],
64    /// Mutable reference to menu open state.
65    pub is_open: &'a mut bool,
66    /// Mutable reference to menu screen spawn position.
67    pub spawn_pos: &'a mut Pos2,
68    /// Mutable reference to currently highlighted menu item index.
69    pub selected_idx: &'a mut Option<usize>,
70    /// Optional macOS dynamic theme reference.
71    pub theme: Option<&'a MacTheme>,
72}
73
74impl<'a> MacContextMenu<'a> {
75    /// Creates a new context menu.
76    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    /// Sets the dynamic theme.
92    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
93        self.theme = Some(theme);
94        self
95    }
96
97    /// Checks a widget response (such as on a canvas card or outline row)
98    /// and activates the context menu if secondary-clicked (right-clicked).
99    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    /// Closes the context menu.
110    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    /// Handles keyboard events (Arrow Down, Arrow Up, Enter, Escape).
141    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    /// Renders the context menu if open, returning action ID if an item was executed.
164    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}