1use 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
54const EDGE_MARGIN: f32 = 8.0;
56
57fn clamp_origin(pos: f32, extent: f32, viewport: f32, margin: f32) -> f32 {
61 pos.min(viewport - extent - margin).max(margin)
62}
63
64fn 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
79pub struct ContextMenu {
83 entries: Vec<Entry>,
84 open: bool,
85 position: Point<Pixels>,
86 focus: FocusHandle,
87 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 pub fn width(mut self, width: f32) -> Self {
115 self.width = width;
116 self
117 }
118
119 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 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 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 pub fn section(mut self, label: impl Into<SharedString>) -> Self {
167 self.entries.push(Entry::Section(label.into()));
168 self
169 }
170
171 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 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 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 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 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 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 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 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 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}