Skip to main content

guise/overlay/
contextmenu.rs

1//! `ContextMenu` — a right-click action menu at the pointer (gpui entity).
2//!
3//! Unlike [`Menu`](super::Menu) there is no trigger element: the parent calls
4//! [`ContextMenu::show`] with the window coordinates of a right-click
5//! (`MouseDownEvent.position`) and renders the entity somewhere in its tree.
6//! While open it paints a deferred, full-viewport backdrop (click-away closes)
7//! with the menu positioned absolutely at the stored point, clamped to stay
8//! on screen. Item clicks run their handler and close; Escape closes.
9//!
10//! ```ignore
11//! let menu = cx.new(|cx| {
12//!     ContextMenu::new(cx)
13//!         .item_icon(IconName::Copy, "Copy", |_w, _app| { /* ... */ })
14//!         .item("Rename", |_w, _app| { /* ... */ })
15//!         .divider()
16//!         .danger_item("Delete", |_w, _app| { /* ... */ })
17//! });
18//!
19//! // In the parent's render:
20//! div()
21//!     .id("target")
22//!     .on_mouse_down(MouseButton::Right, cx.listener(move |this, ev: &MouseDownEvent, window, cx| {
23//!         let position = ev.position;
24//!         this.menu.update(cx, |menu, cx| menu.show(position, window, cx));
25//!     }))
26//!     .child("Right-click me")
27//!     .child(menu.clone()) // renders nothing while closed
28//! ```
29
30use gpui::prelude::*;
31use gpui::{
32    anchored, deferred, div, point, px, App, Context, FocusHandle, IntoElement, KeyDownEvent,
33    Pixels, Point, SharedString, Window,
34};
35
36use crate::icon::{Icon, IconName};
37use crate::input::control_metrics;
38use crate::theme::{theme, ColorName, Size};
39
40type ItemHandler = Box<dyn Fn(&mut Window, &mut App) + 'static>;
41
42enum Entry {
43    Item {
44        label: SharedString,
45        icon: Option<IconName>,
46        danger: bool,
47        handler: Option<ItemHandler>,
48    },
49    Section(SharedString),
50    Divider,
51}
52
53/// Margin kept between the menu and the window edges when clamping.
54const EDGE_MARGIN: f32 = 8.0;
55
56/// Clamp a menu origin so `extent` stays inside `viewport` with a margin on
57/// both edges. Falls back to the margin when the menu is larger than the
58/// viewport (top/left wins).
59fn clamp_origin(pos: f32, extent: f32, viewport: f32, margin: f32) -> f32 {
60    pos.min(viewport - extent - margin).max(margin)
61}
62
63/// Estimated pixel height of the open menu. gpui hands elements no bounds of
64/// their own before paint, so edge clamping works from this heuristic (item
65/// rows track the font metrics used at render time).
66fn estimated_height(entries: &[Entry], font: f32, font_xs: f32) -> f32 {
67    let body: f32 = entries
68        .iter()
69        .map(|entry| match entry {
70            Entry::Item { .. } => font * 1.5 + 12.0,
71            Entry::Section(_) => font_xs * 1.5 + 8.0,
72            Entry::Divider => 9.0,
73        })
74        .sum();
75    body + 8.0
76}
77
78/// A pointer-positioned action menu. Create with
79/// `cx.new(|cx| ContextMenu::new(cx).item(..))` and open it from a
80/// right-click handler via [`ContextMenu::show`].
81pub struct ContextMenu {
82    entries: Vec<Entry>,
83    open: bool,
84    position: Point<Pixels>,
85    focus: FocusHandle,
86    /// Whatever was focused before `show` grabbed focus, restored on close so
87    /// a text field the user was typing in gets its caret back.
88    prev_focus: Option<FocusHandle>,
89    size: Size,
90    width: f32,
91}
92
93impl ContextMenu {
94    pub fn new(cx: &mut Context<Self>) -> Self {
95        ContextMenu {
96            entries: Vec::new(),
97            open: false,
98            position: Point::default(),
99            focus: cx.focus_handle(),
100            prev_focus: None,
101            size: Size::Sm,
102            width: 220.0,
103        }
104    }
105
106    pub fn size(mut self, size: Size) -> Self {
107        self.size = size;
108        self
109    }
110
111    /// Fixed menu width in pixels (default `220.0`). Also drives the
112    /// horizontal edge clamp, so keep it accurate for long labels.
113    pub fn width(mut self, width: f32) -> Self {
114        self.width = width;
115        self
116    }
117
118    /// Add an action item.
119    pub fn item(
120        mut self,
121        label: impl Into<SharedString>,
122        handler: impl Fn(&mut Window, &mut App) + 'static,
123    ) -> Self {
124        self.entries.push(Entry::Item {
125            label: label.into(),
126            icon: None,
127            danger: false,
128            handler: Some(Box::new(handler)),
129        });
130        self
131    }
132
133    /// Add an action item with a leading icon.
134    pub fn item_icon(
135        mut self,
136        icon: IconName,
137        label: impl Into<SharedString>,
138        handler: impl Fn(&mut Window, &mut App) + 'static,
139    ) -> Self {
140        self.entries.push(Entry::Item {
141            label: label.into(),
142            icon: Some(icon),
143            danger: false,
144            handler: Some(Box::new(handler)),
145        });
146        self
147    }
148
149    /// Add a destructive action item (rendered in red).
150    pub fn danger_item(
151        mut self,
152        label: impl Into<SharedString>,
153        handler: impl Fn(&mut Window, &mut App) + 'static,
154    ) -> Self {
155        self.entries.push(Entry::Item {
156            label: label.into(),
157            icon: None,
158            danger: true,
159            handler: Some(Box::new(handler)),
160        });
161        self
162    }
163
164    /// Add a non-interactive section label.
165    pub fn section(mut self, label: impl Into<SharedString>) -> Self {
166        self.entries.push(Entry::Section(label.into()));
167        self
168    }
169
170    /// Add a separating divider.
171    pub fn divider(mut self) -> Self {
172        self.entries.push(Entry::Divider);
173        self
174    }
175
176    pub fn is_open(&self) -> bool {
177        self.open
178    }
179
180    /// Open the menu at a window-coordinate point — pass
181    /// `MouseDownEvent.position` from a `MouseButton::Right` handler. Grabs
182    /// focus so Escape closes; the previous focus is restored on close.
183    pub fn show(&mut self, position: Point<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
184        self.position = position;
185        self.open = true;
186        self.prev_focus = window.focused(cx);
187        window.focus(&self.focus);
188        cx.notify();
189    }
190
191    /// Close the menu, handing focus back to whatever held it before `show`.
192    pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
193        self.open = false;
194        self.restore_focus(window);
195        cx.notify();
196    }
197
198    /// Hand focus back, unless something else (an item handler, a click into
199    /// another field) already took it.
200    fn restore_focus(&mut self, window: &mut Window) {
201        if let Some(prev) = self.prev_focus.take() {
202            if self.focus.is_focused(window) {
203                window.focus(&prev);
204            }
205        }
206    }
207
208    fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
209        if !self.open {
210            return;
211        }
212        if event.keystroke.key.as_str() == "escape" {
213            self.close(window, cx);
214            cx.stop_propagation();
215        }
216    }
217}
218
219impl Render for ContextMenu {
220    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
221        let mut root = div();
222        if !self.open {
223            return root;
224        }
225
226        let t = theme(cx);
227        let (_, _, font) = control_metrics(self.size);
228        let font_xs = t.font_size(Size::Xs);
229        let radius = t.radius(t.default_radius);
230        let surface_color = t.surface().hsla();
231        let surface_hover = t.surface_hover().hsla();
232        let border = t.border().hsla();
233        let text = t.text().hsla();
234        let dimmed = t.dimmed().hsla();
235        let danger = t
236            .color(ColorName::Red, if t.scheme.is_dark() { 5 } else { 6 })
237            .hsla();
238
239        let viewport = window.viewport_size();
240        let height = estimated_height(&self.entries, font, font_xs);
241        let x = clamp_origin(
242            f32::from(self.position.x),
243            self.width,
244            f32::from(viewport.width),
245            EDGE_MARGIN,
246        );
247        let y = clamp_origin(
248            f32::from(self.position.y),
249            height,
250            f32::from(viewport.height),
251            EDGE_MARGIN,
252        );
253
254        let mut menu = div()
255            .id("guise-contextmenu")
256            .occlude()
257            .track_focus(&self.focus)
258            .on_key_down(cx.listener(Self::on_key))
259            .on_click(|_ev, _window, cx| cx.stop_propagation())
260            .absolute()
261            .left(px(x))
262            .top(px(y))
263            .w(px(self.width))
264            .flex()
265            .flex_col()
266            .gap(px(2.0))
267            .p(px(4.0))
268            .rounded(px(radius))
269            .border_1()
270            .border_color(border)
271            .bg(surface_color)
272            .shadow_md();
273
274        for (i, entry) in self.entries.iter().enumerate() {
275            match entry {
276                Entry::Item {
277                    label,
278                    icon,
279                    danger: is_danger,
280                    ..
281                } => {
282                    let mut item = div()
283                        .id(("guise-contextmenu-item", i))
284                        .flex()
285                        .items_center()
286                        .gap(px(8.0))
287                        .px(px(10.0))
288                        .py(px(6.0))
289                        .rounded(px(4.0))
290                        .text_size(px(font))
291                        .text_color(if *is_danger { danger } else { text })
292                        .hover(move |s| s.bg(surface_hover));
293                    if let Some(icon) = icon {
294                        item = item.child(Icon::new(*icon).size(Size::Sm));
295                    }
296                    item = item.child(label.clone());
297                    menu = menu.child(item.on_click(cx.listener(move |this, _ev, window, cx| {
298                        this.open = false;
299                        // Restore first: a handler that focuses something
300                        // (rename → input) still wins.
301                        this.restore_focus(window);
302                        if let Entry::Item {
303                            handler: Some(handler),
304                            ..
305                        } = &this.entries[i]
306                        {
307                            handler(window, cx);
308                        }
309                        cx.notify();
310                    })));
311                }
312                Entry::Section(label) => {
313                    menu = menu.child(
314                        div()
315                            .px(px(10.0))
316                            .pt(px(6.0))
317                            .pb(px(2.0))
318                            .text_size(px(font_xs))
319                            .text_color(dimmed)
320                            .child(label.clone()),
321                    );
322                }
323                Entry::Divider => {
324                    menu = menu.child(div().my(px(4.0)).h(px(1.0)).bg(border));
325                }
326            }
327        }
328
329        // Transparent full-viewport backdrop: occludes the page and closes the
330        // menu on click-away.
331        let backdrop = div()
332            .id("guise-contextmenu-backdrop")
333            .occlude()
334            .absolute()
335            .top(px(0.0))
336            .left(px(0.0))
337            .w(viewport.width)
338            .h(viewport.height)
339            .on_click(cx.listener(|this, _ev, window, cx| {
340                this.close(window, cx);
341            }))
342            .child(menu);
343
344        // The stored point is in window coordinates but `.absolute()` insets
345        // resolve against the parent, so anchor the overlay at the window
346        // origin: backdrop and menu then land in window space no matter where
347        // in the tree this entity is rendered.
348        root = root.child(deferred(
349            anchored().position(point(px(0.0), px(0.0))).child(backdrop),
350        ));
351        root
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn clamp_keeps_fitting_menu_in_place() {
361        assert_eq!(clamp_origin(100.0, 220.0, 800.0, 8.0), 100.0);
362    }
363
364    #[test]
365    fn clamp_pulls_back_from_right_edge() {
366        // 700 + 220 overflows an 800px viewport: clamp to 800 - 220 - 8.
367        assert_eq!(clamp_origin(700.0, 220.0, 800.0, 8.0), 572.0);
368    }
369
370    #[test]
371    fn clamp_never_goes_past_the_margin() {
372        assert_eq!(clamp_origin(-40.0, 220.0, 800.0, 8.0), 8.0);
373        // Menu taller than the viewport: pin to the top margin.
374        assert_eq!(clamp_origin(300.0, 900.0, 600.0, 8.0), 8.0);
375    }
376
377    #[test]
378    fn estimated_height_sums_entry_kinds() {
379        let entries = vec![
380            Entry::Item {
381                label: SharedString::new_static("Copy"),
382                icon: None,
383                danger: false,
384                handler: None,
385            },
386            Entry::Section(SharedString::new_static("Danger")),
387            Entry::Divider,
388        ];
389        let expected = (14.0 * 1.5 + 12.0) + (12.0 * 1.5 + 8.0) + 9.0 + 8.0;
390        assert_eq!(estimated_height(&entries, 14.0, 12.0), expected);
391    }
392}