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