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        let mut triggered = None;
204        let area_id = ui.scope_id().with("mac_context_menu_area");
205
206        Area::new(area_id)
207            .order(Order::Foreground)
208            .fixed_pos(menu_rect.min)
209            .show(ui.ctx(), |menu_ui| {
210                let painter = menu_ui.painter();
211                let corner_r = 6u8;
212
213                painter.rect(
214                    menu_rect,
215                    CornerRadius::same(corner_r),
216                    theme.window_background(),
217                    Stroke::new(1.0, theme.separator()),
218                    StrokeKind::Inside,
219                );
220
221                let font_id = FontId::proportional(12.0);
222
223                for (idx, item) in self.items.iter().enumerate() {
224                    let item_y = menu_rect.top() + padding + (idx as f32) * item_height;
225                    let item_rect = Rect::from_min_size(
226                        Pos2::new(menu_rect.left() + padding, item_y),
227                        Vec2::new(menu_rect.width() - padding * 2.0, item_height),
228                    );
229
230                    if matches!(
231                        item.kind,
232                        crate::navigation::menu::MacMenuItemKind::Separator
233                    ) {
234                        painter.line_segment(
235                            [
236                                Pos2::new(item_rect.left(), item_rect.center().y),
237                                Pos2::new(item_rect.right(), item_rect.center().y),
238                            ],
239                            Stroke::new(1.0, theme.separator()),
240                        );
241                        continue;
242                    }
243
244                    let is_highlighted = *self.selected_idx == Some(idx);
245                    let resp = menu_ui.interact(item_rect, area_id.with(idx), Sense::click());
246
247                    if resp.hovered() && item.enabled {
248                        *self.selected_idx = Some(idx);
249                    }
250
251                    if is_highlighted && item.enabled {
252                        painter.rect(
253                            item_rect,
254                            CornerRadius::same(4),
255                            theme.control_accent(),
256                            Stroke::NONE,
257                            StrokeKind::Inside,
258                        );
259                    }
260
261                    let fg = if !item.enabled {
262                        theme.placeholder_text()
263                    } else if is_highlighted {
264                        Color32::WHITE
265                    } else {
266                        theme.label()
267                    };
268
269                    painter.text(
270                        Pos2::new(item_rect.left() + 8.0, item_rect.center().y),
271                        Align2::LEFT_CENTER,
272                        &item.title,
273                        font_id.clone(),
274                        fg,
275                    );
276
277                    if let crate::navigation::menu::MacMenuItemKind::Action {
278                        shortcut: Some(sc),
279                        ..
280                    } = &item.kind
281                    {
282                        painter.text(
283                            Pos2::new(item_rect.right() - 8.0, item_rect.center().y),
284                            Align2::RIGHT_CENTER,
285                            sc.format_text(),
286                            font_id.clone(),
287                            fg,
288                        );
289                    }
290
291                    if resp.clicked() && item.enabled {
292                        if let crate::navigation::menu::MacMenuItemKind::Action { id, .. } =
293                            &item.kind
294                        {
295                            triggered = Some(id.clone());
296                            self.close();
297                        }
298                    }
299                }
300            });
301
302        triggered
303    }
304}