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, Pixels,
33  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}