1use std::sync::Arc;
2
3use gpui::{
4 IntoElement as _, MouseButton, ParentElement as _, Point, Pixels, SharedString, Styled as _,
5 div, px, rgb,
6};
7
8use crate::{
9 NodeId,
10 plugin::{
11 EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer,
12 },
13};
14
15use super::{
16 clipboard_ops::{extract_subgraph, paste_subgraph},
17 delete::delete_selection,
18 fit_all::fit_entire_graph,
19 focus_selection::focus_viewport_on_selection,
20 select_all_viewport::select_all_in_viewport,
21};
22
23const MENU_W: f32 = 228.0;
24const ROW_H: f32 = 26.0;
25const SEP_H: f32 = 9.0;
26const MENU_PAD: f32 = 4.0;
27
28#[derive(Clone)]
33pub struct ContextMenuCustomAction(
34 Arc<dyn for<'a> Fn(&mut PluginContext<'a>, Point<Pixels>) + Send + Sync>,
35);
36
37impl ContextMenuCustomAction {
38 pub fn new(
39 f: impl for<'a> Fn(&mut PluginContext<'a>, Point<Pixels>) + Send + Sync + 'static,
40 ) -> Self {
41 Self(Arc::new(f))
42 }
43
44 fn call(&self, ctx: &mut PluginContext<'_>, menu_world: Point<Pixels>) {
45 (self.0)(ctx, menu_world);
46 }
47}
48
49#[derive(Clone)]
51pub struct ContextMenuCanvasExtra {
52 pub label: SharedString,
53 pub shortcut: Option<SharedString>,
54 pub on_select: ContextMenuCustomAction,
55}
56
57impl ContextMenuCanvasExtra {
58 pub fn new(
59 label: impl Into<SharedString>,
60 on_select: impl for<'a> Fn(&mut PluginContext<'a>, Point<Pixels>) + Send + Sync + 'static,
61 ) -> Self {
62 Self {
63 label: label.into(),
64 shortcut: None,
65 on_select: ContextMenuCustomAction::new(on_select),
66 }
67 }
68
69 pub fn with_shortcut(
70 label: impl Into<SharedString>,
71 shortcut: impl Into<SharedString>,
72 on_select: impl for<'a> Fn(&mut PluginContext<'a>, Point<Pixels>) + Send + Sync + 'static,
73 ) -> Self {
74 Self {
75 label: label.into(),
76 shortcut: Some(shortcut.into()),
77 on_select: ContextMenuCustomAction::new(on_select),
78 }
79 }
80}
81
82pub struct ContextMenuPlugin {
85 open: Option<OpenMenu>,
86 canvas_extras: Vec<ContextMenuCanvasExtra>,
87}
88
89#[derive(Clone, Copy)]
90enum MenuBuiltin {
91 FitAllGraph,
92 Paste,
93 SelectAllViewport,
94 FocusSelection,
95 Copy,
96 Delete,
97 BringToFront(NodeId),
98}
99
100#[derive(Clone)]
101enum MenuItem {
102 Separator,
103 Builtin(MenuBuiltin),
104 Custom {
105 label: SharedString,
106 shortcut: Option<SharedString>,
107 action: ContextMenuCustomAction,
108 },
109}
110
111#[derive(Clone)]
112struct OpenMenu {
113 anchor: Point<Pixels>,
114 anchor_world: Point<Pixels>,
116 actions: Vec<MenuItem>,
117}
118
119impl ContextMenuPlugin {
120 pub fn new() -> Self {
121 Self {
122 open: None,
123 canvas_extras: Vec::new(),
124 }
125 }
126
127 pub fn with_canvas_extras(canvas_extras: Vec<ContextMenuCanvasExtra>) -> Self {
128 Self {
129 open: None,
130 canvas_extras,
131 }
132 }
133
134 pub fn canvas_row(
136 mut self,
137 label: impl Into<SharedString>,
138 on_select: impl for<'a> Fn(&mut PluginContext<'a>, Point<Pixels>) + Send + Sync + 'static,
139 ) -> Self {
140 self.canvas_extras
141 .push(ContextMenuCanvasExtra::new(label, on_select));
142 self
143 }
144
145 pub fn canvas_row_with_shortcut(
147 mut self,
148 label: impl Into<SharedString>,
149 shortcut: impl Into<SharedString>,
150 on_select: impl for<'a> Fn(&mut PluginContext<'a>, Point<Pixels>) + Send + Sync + 'static,
151 ) -> Self {
152 self.canvas_extras
153 .push(ContextMenuCanvasExtra::with_shortcut(label, shortcut, on_select));
154 self
155 }
156
157 fn row_height(action: &MenuItem) -> f32 {
158 match action {
159 MenuItem::Separator => SEP_H,
160 _ => ROW_H,
161 }
162 }
163
164 fn content_height(actions: &[MenuItem]) -> f32 {
165 actions.iter().map(Self::row_height).sum()
166 }
167
168 fn menu_bounds(anchor: Point<Pixels>, actions: &[MenuItem]) -> gpui::Bounds<Pixels> {
169 let h = Self::content_height(actions) + MENU_PAD * 2.0;
170 gpui::Bounds::new(anchor, gpui::Size::new(px(MENU_W), px(h)))
171 }
172
173 fn label_builtin(b: MenuBuiltin) -> &'static str {
174 match b {
175 MenuBuiltin::FitAllGraph => "Fit entire graph",
176 MenuBuiltin::Paste => "Paste",
177 MenuBuiltin::SelectAllViewport => "Select all in view",
178 MenuBuiltin::FocusSelection => "Focus selection",
179 MenuBuiltin::Copy => "Copy",
180 MenuBuiltin::Delete => "Delete",
181 MenuBuiltin::BringToFront(_) => "Bring to front",
182 }
183 }
184
185 fn shortcut_hint_builtin(b: MenuBuiltin) -> Option<&'static str> {
186 #[cfg(target_os = "macos")]
187 {
188 match b {
189 MenuBuiltin::FitAllGraph => Some("⌘0"),
190 MenuBuiltin::Paste => Some("⌘V"),
191 MenuBuiltin::SelectAllViewport => Some("⌘A"),
192 MenuBuiltin::FocusSelection => Some("⌘⇧F"),
193 MenuBuiltin::Copy => Some("⌘C"),
194 MenuBuiltin::Delete => Some("⌫"),
195 MenuBuiltin::BringToFront(_) => None,
196 }
197 }
198 #[cfg(not(target_os = "macos"))]
199 {
200 match b {
201 MenuBuiltin::FitAllGraph => Some("Ctrl+0"),
202 MenuBuiltin::Paste => Some("Ctrl+V"),
203 MenuBuiltin::SelectAllViewport => Some("Ctrl+A"),
204 MenuBuiltin::FocusSelection => Some("Ctrl+Shift+F"),
205 MenuBuiltin::Copy => Some("Ctrl+C"),
206 MenuBuiltin::Delete => Some("Del"),
207 MenuBuiltin::BringToFront(_) => None,
208 }
209 }
210 }
211
212 fn canvas_actions(&self, ctx: &PluginContext) -> Vec<MenuItem> {
213 let mut v = Vec::new();
214 v.push(MenuItem::Builtin(MenuBuiltin::FitAllGraph));
215 v.push(MenuItem::Separator);
216 if ctx.clipboard_subgraph.is_some() {
217 v.push(MenuItem::Builtin(MenuBuiltin::Paste));
218 v.push(MenuItem::Separator);
219 }
220 v.push(MenuItem::Builtin(MenuBuiltin::SelectAllViewport));
221 v.push(MenuItem::Separator);
222 v.push(MenuItem::Builtin(MenuBuiltin::FocusSelection));
223 for e in &self.canvas_extras {
224 v.push(MenuItem::Separator);
225 v.push(MenuItem::Custom {
226 label: e.label.clone(),
227 shortcut: e.shortcut.clone(),
228 action: e.on_select.clone(),
229 });
230 }
231 v
232 }
233
234 fn node_actions(nid: NodeId) -> Vec<MenuItem> {
235 vec![
236 MenuItem::Builtin(MenuBuiltin::Copy),
237 MenuItem::Separator,
238 MenuItem::Builtin(MenuBuiltin::Delete),
239 MenuItem::Builtin(MenuBuiltin::BringToFront(nid)),
240 MenuItem::Separator,
241 MenuItem::Builtin(MenuBuiltin::FocusSelection),
242 ]
243 }
244
245 fn run_action(ctx: &mut PluginContext, action: &MenuItem, menu_world: Point<Pixels>) {
246 match action {
247 MenuItem::Separator => {}
248 MenuItem::Builtin(b) => match b {
249 MenuBuiltin::FitAllGraph => fit_entire_graph(ctx),
250 MenuBuiltin::Paste => {
251 if let Some(sub) = ctx.clipboard_subgraph.clone() {
252 paste_subgraph(ctx, &sub);
253 }
254 }
255 MenuBuiltin::SelectAllViewport => select_all_in_viewport(ctx),
256 MenuBuiltin::FocusSelection => focus_viewport_on_selection(ctx),
257 MenuBuiltin::Copy => {
258 if let Some(s) = extract_subgraph(ctx.graph) {
259 *ctx.clipboard_subgraph = Some(s);
260 }
261 }
262 MenuBuiltin::Delete => delete_selection(ctx),
263 MenuBuiltin::BringToFront(id) => ctx.bring_node_to_front(*id),
264 },
265 MenuItem::Custom { action, .. } => action.call(ctx, menu_world),
266 }
267 ctx.notify();
268 }
269
270 fn row_at_dy(actions: &[MenuItem], dy: f32) -> Option<usize> {
271 if dy < 0.0 {
272 return None;
273 }
274 let mut y = 0.0;
275 for (i, a) in actions.iter().enumerate() {
276 let h = Self::row_height(a);
277 if dy < y + h {
278 return Some(i);
279 }
280 y += h;
281 }
282 None
283 }
284}
285
286impl Plugin for ContextMenuPlugin {
287 fn name(&self) -> &'static str {
288 "context_menu"
289 }
290
291 fn setup(&mut self, _ctx: &mut crate::plugin::InitPluginContext) {}
292
293 fn priority(&self) -> i32 {
294 132
295 }
296
297 fn render_layer(&self) -> RenderLayer {
298 RenderLayer::Overlay
299 }
300
301 fn render(&mut self, ctx: &mut RenderContext) -> Option<gpui::AnyElement> {
302 let open = self.open.as_ref()?;
303 let panel_bg = ctx.theme.context_menu_background;
304 let panel_border = ctx.theme.context_menu_border;
305 let row_text = ctx.theme.context_menu_text;
306 let shortcut_text = ctx.theme.context_menu_shortcut_text;
307 let separator = ctx.theme.context_menu_separator;
308
309 let rows: Vec<_> = open
310 .actions
311 .iter()
312 .map(|a| match a {
313 MenuItem::Separator => div()
314 .w_full()
315 .h(px(SEP_H))
316 .flex()
317 .items_center()
318 .px_2()
319 .child(
320 div()
321 .w_full()
322 .h(px(1.0))
323 .bg(rgb(separator)),
324 )
325 .into_any_element(),
326 MenuItem::Builtin(b) => {
327 let label = div()
328 .flex_1()
329 .min_w(px(0.))
330 .overflow_hidden()
331 .text_ellipsis()
332 .child(ContextMenuPlugin::label_builtin(*b));
333 let shortcut = ContextMenuPlugin::shortcut_hint_builtin(*b).map(|h| {
334 div()
335 .flex_shrink_0()
336 .ml_2()
337 .text_xs()
338 .text_color(rgb(shortcut_text))
339 .child(h)
340 });
341 div()
342 .w_full()
343 .h(px(ROW_H))
344 .flex()
345 .flex_row()
346 .items_center()
347 .px_2()
348 .text_sm()
349 .text_color(rgb(row_text))
350 .child(label)
351 .children(shortcut)
352 .into_any_element()
353 }
354 MenuItem::Custom {
355 label,
356 shortcut,
357 ..
358 } => {
359 let label_el = div()
360 .flex_1()
361 .min_w(px(0.))
362 .overflow_hidden()
363 .text_ellipsis()
364 .child(label.clone());
365 let shortcut_el = shortcut.as_ref().map(|h| {
366 div()
367 .flex_shrink_0()
368 .ml_2()
369 .text_xs()
370 .text_color(rgb(shortcut_text))
371 .child(h.clone())
372 });
373 div()
374 .w_full()
375 .h(px(ROW_H))
376 .flex()
377 .flex_row()
378 .items_center()
379 .px_2()
380 .text_sm()
381 .text_color(rgb(row_text))
382 .child(label_el)
383 .children(shortcut_el)
384 .into_any_element()
385 }
386 })
387 .collect();
388
389 Some(
390 div()
391 .absolute()
392 .left(open.anchor.x)
393 .top(open.anchor.y)
394 .w(px(MENU_W))
395 .p_1()
396 .bg(rgb(panel_bg))
397 .border_1()
398 .border_color(rgb(panel_border))
399 .rounded(px(6.0))
400 .shadow_sm()
401 .children(rows)
402 .into_any_element(),
403 )
404 }
405
406 fn on_event(
407 &mut self,
408 event: &FlowEvent,
409 ctx: &mut PluginContext,
410 ) -> crate::plugin::EventResult {
411 if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event {
412 if ev.button == MouseButton::Left {
413 if let Some(open) = self.open.take() {
414 let menu_world = open.anchor_world;
415 let b = Self::menu_bounds(open.anchor, &open.actions);
416 if b.contains(&ev.position) {
417 let dy: f32 = (ev.position.y - open.anchor.y).into();
418 let inner_y = dy - MENU_PAD;
419 if let Some(row) = Self::row_at_dy(&open.actions, inner_y) {
420 let a = &open.actions[row];
421 if !matches!(a, MenuItem::Separator) {
422 Self::run_action(ctx, a, menu_world);
423 } else {
424 ctx.notify();
425 }
426 } else {
427 ctx.notify();
428 }
429 return EventResult::Stop;
430 }
431 ctx.notify();
432 return EventResult::Continue;
433 }
434 return EventResult::Continue;
435 }
436
437 if ev.button == MouseButton::Right {
438 let world = ctx.screen_to_world(ev.position);
439 let actions = if let Some(nid) = ctx.hit_node(world) {
440 if !ctx.graph.selected_node.contains(&nid) {
441 ctx.clear_selected_edge();
442 ctx.clear_selected_node();
443 ctx.add_selected_node(nid, false);
444 }
445 Self::node_actions(nid)
446 } else {
447 self.canvas_actions(ctx)
448 };
449 self.open = Some(OpenMenu {
450 anchor: ev.position,
451 anchor_world: world,
452 actions,
453 });
454 ctx.notify();
455 return EventResult::Stop;
456 }
457 }
458 EventResult::Continue
459 }
460}