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