Merge pull request #1333 from zed-industries/editor-mouse-context-menu

Max Brunsfeld created

Editor mouse context menu

Change summary

Cargo.lock                                 |   1 
crates/context_menu/src/context_menu.rs    |   4 
crates/editor/Cargo.toml                   |   1 
crates/editor/src/editor.rs                |  14 ++
crates/editor/src/element.rs               |  24 +++++
crates/editor/src/mouse_context_menu.rs    | 103 ++++++++++++++++++++++++
crates/editor/src/selections_collection.rs |  38 ++++++++
7 files changed, 181 insertions(+), 4 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1611,6 +1611,7 @@ dependencies = [
  "anyhow",
  "clock",
  "collections",
+ "context_menu",
  "ctor",
  "env_logger",
  "futures",

crates/context_menu/src/context_menu.rs 🔗

@@ -124,6 +124,10 @@ impl ContextMenu {
         }
     }
 
+    pub fn visible(&self) -> bool {
+        self.visible
+    }
+
     fn action_dispatched(&mut self, action_id: TypeId, cx: &mut ViewContext<Self>) {
         if let Some(ix) = self
             .items

crates/editor/Cargo.toml 🔗

@@ -23,6 +23,7 @@ test-support = [
 text = { path = "../text" }
 clock = { path = "../clock" }
 collections = { path = "../collections" }
+context_menu = { path = "../context_menu" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }

crates/editor/src/editor.rs 🔗

@@ -4,6 +4,7 @@ mod highlight_matching_bracket;
 mod hover_popover;
 pub mod items;
 mod link_go_to_definition;
+mod mouse_context_menu;
 pub mod movement;
 mod multi_buffer;
 pub mod selections_collection;
@@ -319,6 +320,7 @@ pub fn init(cx: &mut MutableAppContext) {
 
     hover_popover::init(cx);
     link_go_to_definition::init(cx);
+    mouse_context_menu::init(cx);
 
     workspace::register_project_item::<Editor>(cx);
     workspace::register_followable_item::<Editor>(cx);
@@ -425,6 +427,7 @@ pub struct Editor {
     background_highlights: BTreeMap<TypeId, (fn(&Theme) -> Color, Vec<Range<Anchor>>)>,
     nav_history: Option<ItemNavHistory>,
     context_menu: Option<ContextMenu>,
+    mouse_context_menu: ViewHandle<context_menu::ContextMenu>,
     completion_tasks: Vec<(CompletionId, Task<Option<()>>)>,
     next_completion_id: CompletionId,
     available_code_actions: Option<(ModelHandle<Buffer>, Arc<[CodeAction]>)>,
@@ -1010,11 +1013,11 @@ impl Editor {
             background_highlights: Default::default(),
             nav_history: None,
             context_menu: None,
+            mouse_context_menu: cx.add_view(|cx| context_menu::ContextMenu::new(cx)),
             completion_tasks: Default::default(),
             next_completion_id: 0,
             available_code_actions: Default::default(),
             code_actions_task: Default::default(),
-
             document_highlights_task: Default::default(),
             pending_rename: Default::default(),
             searchable: true,
@@ -1596,7 +1599,7 @@ impl Editor {
                 s.delete(newest_selection.id)
             }
 
-            s.set_pending_range(start..end, mode);
+            s.set_pending_anchor_range(start..end, mode);
         });
     }
 
@@ -5784,7 +5787,12 @@ impl View for Editor {
             });
         }
 
-        EditorElement::new(self.handle.clone(), style.clone(), self.cursor_shape).boxed()
+        Stack::new()
+            .with_child(
+                EditorElement::new(self.handle.clone(), style.clone(), self.cursor_shape).boxed(),
+            )
+            .with_child(ChildView::new(&self.mouse_context_menu).boxed())
+            .boxed()
     }
 
     fn ui_name() -> &'static str {

crates/editor/src/element.rs 🔗

@@ -7,6 +7,7 @@ use crate::{
     display_map::{BlockStyle, DisplaySnapshot, TransformBlock},
     hover_popover::HoverAt,
     link_go_to_definition::{CmdChanged, GoToFetchedDefinition, UpdateGoToDefinitionLink},
+    mouse_context_menu::DeployMouseContextMenu,
     EditorStyle,
 };
 use clock::ReplicaId;
@@ -152,6 +153,24 @@ impl EditorElement {
         true
     }
 
+    fn mouse_right_down(
+        &self,
+        position: Vector2F,
+        layout: &mut LayoutState,
+        paint: &mut PaintState,
+        cx: &mut EventContext,
+    ) -> bool {
+        if !paint.text_bounds.contains_point(position) {
+            return false;
+        }
+
+        let snapshot = self.snapshot(cx.app);
+        let (point, _) = paint.point_for_position(&snapshot, layout, position);
+
+        cx.dispatch_action(DeployMouseContextMenu { position, point });
+        true
+    }
+
     fn mouse_up(&self, _position: Vector2F, cx: &mut EventContext) -> bool {
         if self.view(cx.app.as_ref()).is_selecting() {
             cx.dispatch_action(Select(SelectPhase::End));
@@ -1482,6 +1501,11 @@ impl Element for EditorElement {
                 paint,
                 cx,
             ),
+            Event::MouseDown(MouseEvent {
+                button: MouseButton::Right,
+                position,
+                ..
+            }) => self.mouse_right_down(*position, layout, paint, cx),
             Event::MouseUp(MouseEvent {
                 button: MouseButton::Left,
                 position,

crates/editor/src/mouse_context_menu.rs 🔗

@@ -0,0 +1,103 @@
+use context_menu::ContextMenuItem;
+use gpui::{geometry::vector::Vector2F, impl_internal_actions, MutableAppContext, ViewContext};
+
+use crate::{
+    DisplayPoint, Editor, EditorMode, FindAllReferences, GoToDefinition, Rename, SelectMode,
+    ToggleCodeActions,
+};
+
+#[derive(Clone, PartialEq)]
+pub struct DeployMouseContextMenu {
+    pub position: Vector2F,
+    pub point: DisplayPoint,
+}
+
+impl_internal_actions!(editor, [DeployMouseContextMenu]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(deploy_context_menu);
+}
+
+pub fn deploy_context_menu(
+    editor: &mut Editor,
+    &DeployMouseContextMenu { position, point }: &DeployMouseContextMenu,
+    cx: &mut ViewContext<Editor>,
+) {
+    // Don't show context menu for inline editors
+    if editor.mode() != EditorMode::Full {
+        return;
+    }
+
+    // Don't show the context menu if there isn't a project associated with this editor
+    if editor.project.is_none() {
+        return;
+    }
+
+    // Move the cursor to the clicked location so that dispatched actions make sense
+    editor.change_selections(None, cx, |s| {
+        s.clear_disjoint();
+        s.set_pending_display_range(point..point, SelectMode::Character);
+    });
+
+    editor.mouse_context_menu.update(cx, |menu, cx| {
+        menu.show(
+            position,
+            vec![
+                ContextMenuItem::item("Rename Symbol", Rename),
+                ContextMenuItem::item("Go To Definition", GoToDefinition),
+                ContextMenuItem::item("Find All References", FindAllReferences),
+                ContextMenuItem::item(
+                    "Code Actions",
+                    ToggleCodeActions {
+                        deployed_from_indicator: false,
+                    },
+                ),
+            ],
+            cx,
+        );
+    });
+    cx.notify();
+}
+
+#[cfg(test)]
+mod tests {
+    use indoc::indoc;
+
+    use crate::test::EditorLspTestContext;
+
+    use super::*;
+
+    #[gpui::test]
+    async fn test_mouse_context_menu(cx: &mut gpui::TestAppContext) {
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
+                ..Default::default()
+            },
+            cx,
+        )
+        .await;
+
+        cx.set_state(indoc! {"
+            fn te|st()
+                do_work();"});
+        let point = cx.display_point(indoc! {"
+            fn test()
+                do_w|ork();"});
+        cx.update_editor(|editor, cx| {
+            deploy_context_menu(
+                editor,
+                &DeployMouseContextMenu {
+                    position: Default::default(),
+                    point,
+                },
+                cx,
+            )
+        });
+
+        cx.assert_editor_state(indoc! {"
+            fn test()
+                do_w|ork();"});
+        cx.editor(|editor, app| assert!(editor.mouse_context_menu.read(app).visible()));
+    }
+}

crates/editor/src/selections_collection.rs 🔗

@@ -384,7 +384,7 @@ impl<'a> MutableSelectionsCollection<'a> {
         }
     }
 
-    pub fn set_pending_range(&mut self, range: Range<Anchor>, mode: SelectMode) {
+    pub fn set_pending_anchor_range(&mut self, range: Range<Anchor>, mode: SelectMode) {
         self.collection.pending = Some(PendingSelection {
             selection: Selection {
                 id: post_inc(&mut self.collection.next_selection_id),
@@ -398,6 +398,42 @@ impl<'a> MutableSelectionsCollection<'a> {
         self.selections_changed = true;
     }
 
+    pub fn set_pending_display_range(&mut self, range: Range<DisplayPoint>, mode: SelectMode) {
+        let (start, end, reversed) = {
+            let display_map = self.display_map();
+            let buffer = self.buffer();
+            let mut start = range.start;
+            let mut end = range.end;
+            let reversed = if start > end {
+                mem::swap(&mut start, &mut end);
+                true
+            } else {
+                false
+            };
+
+            let end_bias = if end > start { Bias::Left } else { Bias::Right };
+            (
+                buffer.anchor_before(start.to_point(&display_map)),
+                buffer.anchor_at(end.to_point(&display_map), end_bias),
+                reversed,
+            )
+        };
+
+        let new_pending = PendingSelection {
+            selection: Selection {
+                id: post_inc(&mut self.collection.next_selection_id),
+                start,
+                end,
+                reversed,
+                goal: SelectionGoal::None,
+            },
+            mode,
+        };
+
+        self.collection.pending = Some(new_pending);
+        self.selections_changed = true;
+    }
+
     pub fn set_pending(&mut self, selection: Selection<Anchor>, mode: SelectMode) {
         self.collection.pending = Some(PendingSelection { selection, mode });
         self.selections_changed = true;