Skip to main content

ferrum_flow/plugins/
context_menu.rs

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/// Callback invoked when the user picks a custom canvas menu row (e.g. open an input dialog in the app).
29///
30/// The second argument is the **world-space** point under the initial right-click that opened this menu
31/// (same as [`PluginContext::screen_to_world`] applied to that click).
32#[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/// One extra row on the **canvas background** context menu (after built-in items).
50#[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
82/// Right-click menu on the canvas (empty area) or on a node. Optional [`ContextMenuCanvasExtra`] rows
83/// are appended after built-in canvas actions.
84pub 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    /// World position of the right-click that opened this menu.
115    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    /// Append a canvas-background row with a custom label (e.g. “Add node…” → show input in meili).
135    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    /// Same as [`Self::canvas_row`] but with a shortcut hint string shown on the right.
146    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}