Introduce keyboard navigation in context menus

Antonio Scandurra created

Change summary

Cargo.lock                                  | 15 ++++
crates/chat_panel/Cargo.toml                |  1 
crates/chat_panel/src/chat_panel.rs         |  2 
crates/contacts_panel/Cargo.toml            |  1 
crates/contacts_panel/src/contacts_panel.rs |  7 -
crates/context_menu/Cargo.toml              |  1 
crates/context_menu/src/context_menu.rs     | 83 ++++++++++++++++++++--
crates/file_finder/Cargo.toml               |  1 
crates/file_finder/src/file_finder.rs       |  6 -
crates/go_to_line/Cargo.toml                |  3 
crates/go_to_line/src/go_to_line.rs         |  6 -
crates/menu/Cargo.toml                      | 11 +++
crates/menu/src/menu.rs                     |  0 
crates/picker/Cargo.toml                    |  1 
crates/picker/src/picker.rs                 |  4 
crates/project_panel/Cargo.toml             |  1 
crates/project_panel/src/project_panel.rs   |  6 -
crates/search/Cargo.toml                    |  1 
crates/search/src/project_search.rs         |  4 
crates/workspace/src/workspace.rs           |  1 
20 files changed, 121 insertions(+), 34 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -665,6 +665,7 @@ dependencies = [
  "client",
  "editor",
  "gpui",
+ "menu",
  "postage",
  "settings",
  "theme",
@@ -964,6 +965,7 @@ dependencies = [
  "gpui",
  "language",
  "log",
+ "menu",
  "picker",
  "postage",
  "project",
@@ -979,6 +981,7 @@ name = "context_menu"
 version = "0.1.0"
 dependencies = [
  "gpui",
+ "menu",
  "settings",
  "smallvec",
  "theme",
@@ -1526,6 +1529,7 @@ dependencies = [
  "env_logger",
  "fuzzy",
  "gpui",
+ "menu",
  "picker",
  "postage",
  "project",
@@ -1904,6 +1908,7 @@ version = "0.1.0"
 dependencies = [
  "editor",
  "gpui",
+ "menu",
  "postage",
  "settings",
  "text",
@@ -2698,6 +2703,13 @@ dependencies = [
  "autocfg 1.0.1",
 ]
 
+[[package]]
+name = "menu"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+]
+
 [[package]]
 name = "metal"
 version = "0.21.0"
@@ -3252,6 +3264,7 @@ dependencies = [
  "editor",
  "env_logger",
  "gpui",
+ "menu",
  "serde_json",
  "settings",
  "theme",
@@ -3463,6 +3476,7 @@ dependencies = [
  "editor",
  "futures",
  "gpui",
+ "menu",
  "postage",
  "project",
  "serde_json",
@@ -4176,6 +4190,7 @@ dependencies = [
  "gpui",
  "language",
  "log",
+ "menu",
  "postage",
  "project",
  "serde",

crates/chat_panel/Cargo.toml 🔗

@@ -11,6 +11,7 @@ doctest = false
 client = { path = "../client" }
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }
+menu = { path = "../menu" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
 util = { path = "../util" }

crates/chat_panel/src/chat_panel.rs 🔗

@@ -11,12 +11,12 @@ use gpui::{
     AppContext, Entity, ModelHandle, MutableAppContext, RenderContext, Subscription, Task, View,
     ViewContext, ViewHandle,
 };
+use menu::Confirm;
 use postage::prelude::Stream;
 use settings::{Settings, SoftWrap};
 use std::sync::Arc;
 use time::{OffsetDateTime, UtcOffset};
 use util::{ResultExt, TryFutureExt};
-use workspace::menu::Confirm;
 
 const MESSAGE_LOADING_THRESHOLD: usize = 50;
 

crates/contacts_panel/Cargo.toml 🔗

@@ -12,6 +12,7 @@ client = { path = "../client" }
 editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
+menu = { path = "../menu" }
 picker = { path = "../picker" }
 project = { path = "../project" }
 settings = { path = "../settings" }

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -16,15 +16,12 @@ use gpui::{
     MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use join_project_notification::JoinProjectNotification;
+use menu::{Confirm, SelectNext, SelectPrev};
 use serde::Deserialize;
 use settings::Settings;
 use std::sync::Arc;
 use theme::IconButton;
-use workspace::{
-    menu::{Confirm, SelectNext, SelectPrev},
-    sidebar::SidebarItem,
-    JoinProject, Workspace,
-};
+use workspace::{sidebar::SidebarItem, JoinProject, Workspace};
 
 impl_actions!(
     contacts_panel,

crates/context_menu/Cargo.toml 🔗

@@ -9,6 +9,7 @@ doctest = false
 
 [dependencies]
 gpui = { path = "../gpui" }
+menu = { path = "../menu" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
 smallvec = "1.6"

crates/context_menu/src/context_menu.rs 🔗

@@ -1,18 +1,19 @@
 use gpui::{
-    elements::*, geometry::vector::Vector2F, impl_internal_actions, platform::CursorStyle, Action,
+    elements::*, geometry::vector::Vector2F, keymap, platform::CursorStyle, Action, AppContext,
     Axis, Entity, MutableAppContext, RenderContext, SizeConstraint, View, ViewContext,
 };
+use menu::*;
 use settings::Settings;
 
 pub fn init(cx: &mut MutableAppContext) {
-    cx.add_action(ContextMenu::dismiss);
+    cx.add_action(ContextMenu::select_first);
+    cx.add_action(ContextMenu::select_last);
+    cx.add_action(ContextMenu::select_next);
+    cx.add_action(ContextMenu::select_prev);
+    cx.add_action(ContextMenu::confirm);
+    cx.add_action(ContextMenu::cancel);
 }
 
-#[derive(Clone)]
-struct Dismiss;
-
-impl_internal_actions!(context_menu, [Dismiss]);
-
 pub enum ContextMenuItem {
     Item {
         label: String,
@@ -32,6 +33,10 @@ impl ContextMenuItem {
     pub fn separator() -> Self {
         Self::Separator
     }
+
+    fn is_separator(&self) -> bool {
+        matches!(self, Self::Separator)
+    }
 }
 
 #[derive(Default)]
@@ -52,6 +57,12 @@ impl View for ContextMenu {
         "ContextMenu"
     }
 
+    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
+        let mut cx = Self::default_keymap_context();
+        cx.set.insert("menu".into());
+        cx
+    }
+
     fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
         if !self.visible {
             return Empty::new().boxed();
@@ -77,6 +88,7 @@ impl View for ContextMenu {
 
     fn on_blur(&mut self, cx: &mut ViewContext<Self>) {
         self.visible = false;
+        self.selected_index.take();
         cx.notify();
     }
 }
@@ -86,13 +98,66 @@ impl ContextMenu {
         Default::default()
     }
 
-    fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext<Self>) {
+    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.selected_index {
+            if let Some(ContextMenuItem::Item { action, .. }) = self.items.get(ix) {
+                let window_id = cx.window_id();
+                let view_id = cx.view_id();
+                cx.dispatch_action_at(window_id, view_id, action.as_ref());
+            }
+        }
+    }
+
+    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
         if cx.handle().is_focused(cx) {
             let window_id = cx.window_id();
             (**cx).focus(window_id, self.previously_focused_view_id.take());
         }
     }
 
+    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
+        self.selected_index = self.items.iter().position(|item| !item.is_separator());
+        cx.notify();
+    }
+
+    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
+        for (ix, item) in self.items.iter().enumerate().rev() {
+            if !item.is_separator() {
+                self.selected_index = Some(ix);
+                cx.notify();
+                break;
+            }
+        }
+    }
+
+    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.selected_index {
+            for (ix, item) in self.items.iter().enumerate().skip(ix + 1) {
+                if !item.is_separator() {
+                    self.selected_index = Some(ix);
+                    cx.notify();
+                    break;
+                }
+            }
+        } else {
+            self.select_first(&Default::default(), cx);
+        }
+    }
+
+    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.selected_index {
+            for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
+                if !item.is_separator() {
+                    self.selected_index = Some(ix);
+                    cx.notify();
+                    break;
+                }
+            }
+        } else {
+            self.select_last(&Default::default(), cx);
+        }
+    }
+
     pub fn show(
         &mut self,
         position: Vector2F,
@@ -202,7 +267,7 @@ impl ContextMenu {
                         .with_cursor_style(CursorStyle::PointingHand)
                         .on_click(move |_, _, cx| {
                             cx.dispatch_any_action(action.boxed_clone());
-                            cx.dispatch_action(Dismiss);
+                            cx.dispatch_action(Cancel);
                         })
                         .boxed()
                     }

crates/file_finder/Cargo.toml 🔗

@@ -11,6 +11,7 @@ doctest = false
 editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
+menu = { path = "../menu" }
 picker = { path = "../picker" }
 project = { path = "../project" }
 settings = { path = "../settings" }

crates/file_finder/src/file_finder.rs 🔗

@@ -257,11 +257,9 @@ impl PickerDelegate for FileFinder {
 mod tests {
     use super::*;
     use editor::{Editor, Input};
+    use menu::{Confirm, SelectNext};
     use serde_json::json;
-    use workspace::{
-        menu::{Confirm, SelectNext},
-        AppState, Workspace,
-    };
+    use workspace::{AppState, Workspace};
 
     #[ctor::ctor]
     fn init_logger() {

crates/go_to_line/Cargo.toml 🔗

@@ -8,9 +8,10 @@ path = "src/go_to_line.rs"
 doctest = false
 
 [dependencies]
-text = { path = "../text" }
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }
+menu = { path = "../menu" }
 settings = { path = "../settings" }
+text = { path = "../text" }
 workspace = { path = "../workspace" }
 postage = { version = "0.4", features = ["futures-traits"] }

crates/go_to_line/src/go_to_line.rs 🔗

@@ -3,12 +3,10 @@ use gpui::{
     actions, elements::*, geometry::vector::Vector2F, Axis, Entity, MutableAppContext,
     RenderContext, View, ViewContext, ViewHandle,
 };
+use menu::{Cancel, Confirm};
 use settings::Settings;
 use text::{Bias, Point};
-use workspace::{
-    menu::{Cancel, Confirm},
-    Workspace,
-};
+use workspace::Workspace;
 
 actions!(go_to_line, [Toggle]);
 

crates/menu/Cargo.toml 🔗

@@ -0,0 +1,11 @@
+[package]
+name = "menu"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/menu.rs"
+doctest = false
+
+[dependencies]
+gpui = { path = "../gpui" }

crates/picker/Cargo.toml 🔗

@@ -10,6 +10,7 @@ doctest = false
 [dependencies]
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }
+menu = { path = "../menu" }
 settings = { path = "../settings" }
 util = { path = "../util" }
 theme = { path = "../theme" }

crates/picker/src/picker.rs 🔗

@@ -10,11 +10,9 @@ use gpui::{
     AppContext, Axis, Element, ElementBox, Entity, MutableAppContext, RenderContext, Task, View,
     ViewContext, ViewHandle, WeakViewHandle,
 };
+use menu::{Cancel, Confirm, SelectFirst, SelectIndex, SelectLast, SelectNext, SelectPrev};
 use settings::Settings;
 use std::cmp;
-use workspace::menu::{
-    Cancel, Confirm, SelectFirst, SelectIndex, SelectLast, SelectNext, SelectPrev,
-};
 
 pub struct Picker<D: PickerDelegate> {
     delegate: WeakViewHandle<D>,

crates/project_panel/Cargo.toml 🔗

@@ -11,6 +11,7 @@ doctest = false
 context_menu = { path = "../context_menu" }
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }
+menu = { path = "../menu" }
 project = { path = "../project" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }

crates/project_panel/src/project_panel.rs 🔗

@@ -14,6 +14,7 @@ use gpui::{
     AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, PromptLevel, Task,
     View, ViewContext, ViewHandle, WeakViewHandle,
 };
+use menu::{Confirm, SelectNext, SelectPrev};
 use project::{Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
 use settings::Settings;
 use std::{
@@ -23,10 +24,7 @@ use std::{
     ops::Range,
 };
 use unicase::UniCase;
-use workspace::{
-    menu::{Confirm, SelectNext, SelectPrev},
-    Workspace,
-};
+use workspace::Workspace;
 
 const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
 

crates/search/Cargo.toml 🔗

@@ -12,6 +12,7 @@ collections = { path = "../collections" }
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
+menu = { path = "../menu" }
 project = { path = "../project" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }

crates/search/src/project_search.rs 🔗

@@ -9,6 +9,7 @@ use gpui::{
     ModelHandle, MutableAppContext, RenderContext, Subscription, Task, View, ViewContext,
     ViewHandle, WeakModelHandle, WeakViewHandle,
 };
+use menu::Confirm;
 use project::{search::SearchQuery, Project};
 use settings::Settings;
 use smallvec::SmallVec;
@@ -19,8 +20,7 @@ use std::{
 };
 use util::ResultExt as _;
 use workspace::{
-    menu::Confirm, Item, ItemHandle, ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView,
-    Workspace,
+    Item, ItemHandle, ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace,
 };
 
 actions!(project_search, [Deploy, SearchInNew, ToggleFocus]);