Add keymap picker UI

Mikayla Maki and Max created

Co-authored-by: Max <max@zed.dev>

Change summary

Cargo.lock                                |   2 
crates/project_panel/src/project_panel.rs |   2 
crates/settings/src/keymap_file.rs        |   4 
crates/settings/src/settings.rs           |  46 ++++--
crates/welcome/Cargo.toml                 |   2 
crates/welcome/src/base_keymap_picker.rs  | 175 +++++++++++++++++++++++++
crates/welcome/src/welcome.rs             |  17 +
crates/zed/src/zed.rs                     |   3 
8 files changed, 225 insertions(+), 26 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -8030,9 +8030,11 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "editor",
+ "fuzzy",
  "gpui",
  "install_cli",
  "log",
+ "picker",
  "project",
  "settings",
  "theme",

crates/project_panel/src/project_panel.rs 🔗

@@ -1329,7 +1329,7 @@ impl View for ProjectPanel {
 
                             keystroke_label(
                                 parent_view_id,
-                                "Open a new project",
+                                "Open project",
                                 &button_style,
                                 context_menu_item.keystroke,
                                 workspace::Open,

crates/settings/src/keymap_file.rs 🔗

@@ -46,8 +46,8 @@ impl KeymapFileContent {
             Self::load(path, cx).unwrap();
         }
 
-        if let Some(base_keymap) = cx.global::<Settings>().base_keymap {
-            Self::load(base_keymap.asset_path(), cx).log_err();
+        if let Some(asset_path) = cx.global::<Settings>().base_keymap.asset_path() {
+            Self::load(asset_path, cx).log_err();
         }
     }
 

crates/settings/src/settings.rs 🔗

@@ -55,24 +55,46 @@ pub struct Settings {
     pub telemetry_defaults: TelemetrySettings,
     pub telemetry_overrides: TelemetrySettings,
     pub auto_update: bool,
-    pub base_keymap: Option<BaseKeymap>,
+    pub base_keymap: BaseKeymap,
 }
 
-#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
 pub enum BaseKeymap {
+    #[default]
+    VSCode,
     JetBrains,
     Sublime,
     Atom,
 }
 
 impl BaseKeymap {
-    pub fn asset_path(&self) -> &str {
+    pub const OPTIONS: [(&'static str, Self); 4] = [
+        ("VSCode (Default)", Self::VSCode),
+        ("Atom", Self::Atom),
+        ("JetBrains", Self::JetBrains),
+        ("Sublime", Self::Sublime),
+    ];
+
+    pub fn asset_path(&self) -> Option<&'static str> {
         match self {
-            BaseKeymap::JetBrains => "keymaps/jetbrains.json",
-            BaseKeymap::Sublime => "keymaps/sublime_text.json",
-            BaseKeymap::Atom => "keymaps/atom.json",
+            BaseKeymap::JetBrains => Some("keymaps/jetbrains.json"),
+            BaseKeymap::Sublime => Some("keymaps/sublime_text.json"),
+            BaseKeymap::Atom => Some("keymaps/atom.json"),
+            BaseKeymap::VSCode => None,
         }
     }
+
+    pub fn names() -> impl Iterator<Item = &'static str> {
+        Self::OPTIONS.iter().map(|(name, _)| *name)
+    }
+
+    pub fn from_names(option: &str) -> BaseKeymap {
+        Self::OPTIONS
+            .iter()
+            .copied()
+            .find_map(|(name, value)| (name == option).then(|| value))
+            .unwrap_or_default()
+    }
 }
 
 #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
@@ -455,7 +477,7 @@ impl Settings {
         merge(&mut self.vim_mode, data.vim_mode);
         merge(&mut self.autosave, data.autosave);
         merge(&mut self.default_dock_anchor, data.default_dock_anchor);
-        merge(&mut self.base_keymap, Some(data.base_keymap));
+        merge(&mut self.base_keymap, data.base_keymap);
 
         // Ensure terminal font is loaded, so we can request it in terminal_element layout
         if let Some(terminal_font) = &data.terminal.font_family {
@@ -633,7 +655,7 @@ impl Settings {
             },
             telemetry_overrides: Default::default(),
             auto_update: true,
-            base_keymap: None,
+            base_keymap: Default::default(),
         }
     }
 
@@ -722,13 +744,7 @@ pub fn parse_json_with_comments<T: DeserializeOwned>(content: &str) -> Result<T>
     )?)
 }
 
-/// Expects the key to be unquoted, and the value to be valid JSON
-/// (e.g. values should be unquoted for numbers and bools, quoted for strings)
-pub fn write_settings_key<T: ?Sized + Serialize + Clone>(
-    settings_content: &mut String,
-    key_path: &[&str],
-    new_value: &T,
-) {
+fn write_settings_key(settings_content: &mut String, key_path: &[&str], new_value: &Value) {
     let mut parser = tree_sitter::Parser::new();
     parser.set_language(tree_sitter_json::language()).unwrap();
     let tree = parser.parse(&settings_content, None).unwrap();

crates/welcome/Cargo.toml 🔗

@@ -14,6 +14,7 @@ test-support = []
 anyhow = "1.0.38"
 log = "0.4"
 editor = { path = "../editor" }
+fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
 install_cli = { path = "../install_cli" }
 project = { path = "../project" }
@@ -21,4 +22,5 @@ settings = { path = "../settings" }
 theme = { path = "../theme" }
 theme_selector = { path = "../theme_selector" }
 util = { path = "../util" }
+picker = { path = "../picker" }
 workspace = { path = "../workspace" }

crates/welcome/src/base_keymap_picker.rs 🔗

@@ -0,0 +1,175 @@
+use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
+use gpui::{
+    actions,
+    elements::{ChildView, Element as _, Label},
+    AnyViewHandle, Entity, MutableAppContext, View, ViewContext, ViewHandle,
+};
+use picker::{Picker, PickerDelegate};
+use settings::{settings_file::SettingsFile, BaseKeymap, Settings};
+use workspace::Workspace;
+
+pub struct BaseKeymapSelector {
+    matches: Vec<StringMatch>,
+    picker: ViewHandle<Picker<Self>>,
+    selected_index: usize,
+}
+
+actions!(welcome, [ToggleBaseKeymapSelector]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    Picker::<BaseKeymapSelector>::init(cx);
+    cx.add_action({
+        move |workspace, _: &ToggleBaseKeymapSelector, cx| BaseKeymapSelector::toggle(workspace, cx)
+    });
+}
+
+pub enum Event {
+    Dismissed,
+}
+
+impl BaseKeymapSelector {
+    fn toggle(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
+        workspace.toggle_modal(cx, |_, cx| {
+            let this = cx.add_view(|cx| Self::new(cx));
+            cx.subscribe(&this, Self::on_event).detach();
+            this
+        });
+    }
+
+    fn new(cx: &mut ViewContext<Self>) -> Self {
+        let base = cx.global::<Settings>().base_keymap;
+        let selected_index = BaseKeymap::OPTIONS
+            .iter()
+            .position(|(_, value)| *value == base)
+            .unwrap_or(0);
+
+        let this = cx.weak_handle();
+        Self {
+            picker: cx.add_view(|cx| Picker::new("Select a base keymap", this, cx)),
+            matches: Vec::new(),
+            selected_index,
+        }
+    }
+
+    fn on_event(
+        workspace: &mut Workspace,
+        _: ViewHandle<BaseKeymapSelector>,
+        event: &Event,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        match event {
+            Event::Dismissed => {
+                workspace.dismiss_modal(cx);
+            }
+        }
+    }
+}
+
+impl Entity for BaseKeymapSelector {
+    type Event = Event;
+}
+
+impl View for BaseKeymapSelector {
+    fn ui_name() -> &'static str {
+        "BaseKeymapSelector"
+    }
+
+    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
+        ChildView::new(self.picker.clone(), cx).boxed()
+    }
+
+    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if cx.is_self_focused() {
+            cx.focus(&self.picker);
+        }
+    }
+}
+
+impl PickerDelegate for BaseKeymapSelector {
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Self>) {
+        self.selected_index = ix;
+    }
+
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) -> gpui::Task<()> {
+        let background = cx.background().clone();
+        let candidates = BaseKeymap::names()
+            .enumerate()
+            .map(|(id, name)| StringMatchCandidate {
+                id,
+                char_bag: name.into(),
+                string: name.into(),
+            })
+            .collect::<Vec<_>>();
+
+        cx.spawn(|this, mut cx| async move {
+            let matches = if query.is_empty() {
+                candidates
+                    .into_iter()
+                    .enumerate()
+                    .map(|(index, candidate)| StringMatch {
+                        candidate_id: index,
+                        string: candidate.string,
+                        positions: Vec::new(),
+                        score: 0.0,
+                    })
+                    .collect()
+            } else {
+                match_strings(
+                    &candidates,
+                    &query,
+                    false,
+                    100,
+                    &Default::default(),
+                    background,
+                )
+                .await
+            };
+
+            this.update(&mut cx, |this, cx| {
+                this.matches = matches;
+                this.selected_index = this
+                    .selected_index
+                    .min(this.matches.len().saturating_sub(1));
+                cx.notify();
+            });
+        })
+    }
+
+    fn confirm(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(selection) = self.matches.get(self.selected_index) {
+            let base_keymap = BaseKeymap::from_names(&selection.string);
+            SettingsFile::update(cx, move |settings| settings.base_keymap = Some(base_keymap));
+        }
+        cx.emit(Event::Dismissed);
+    }
+
+    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
+        cx.emit(Event::Dismissed)
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        mouse_state: &mut gpui::MouseState,
+        selected: bool,
+        cx: &gpui::AppContext,
+    ) -> gpui::ElementBox {
+        let theme = &cx.global::<Settings>().theme;
+        let keymap_match = &self.matches[ix];
+        let style = theme.picker.item.style_for(mouse_state, selected);
+
+        Label::new(keymap_match.string.clone(), style.label.clone())
+            .with_highlights(keymap_match.positions.clone())
+            .contained()
+            .with_style(style.container)
+            .boxed()
+    }
+}

crates/welcome/src/welcome.rs 🔗

@@ -1,3 +1,5 @@
+mod base_keymap_picker;
+
 use std::borrow::Cow;
 
 use gpui::{
@@ -9,11 +11,15 @@ use settings::{settings_file::SettingsFile, Settings, SettingsFileContent};
 use theme::CheckboxStyle;
 use workspace::{item::Item, PaneBackdrop, Welcome, Workspace, WorkspaceId};
 
+use crate::base_keymap_picker::ToggleBaseKeymapSelector;
+
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(|workspace: &mut Workspace, _: &Welcome, cx| {
         let welcome_page = cx.add_view(WelcomePage::new);
         workspace.add_item(Box::new(welcome_page), cx)
-    })
+    });
+
+    base_keymap_picker::init(cx);
 }
 
 pub struct WelcomePage {
@@ -64,9 +70,9 @@ impl View for WelcomePage {
                     .contained()
                     .with_style(theme.welcome.logo_subheading.container)
                     .boxed(),
-                    self.render_cta_button(2, "Choose a theme", theme_selector::Toggle, width, cx),
-                    self.render_cta_button(3, "Choose a keymap", theme_selector::Toggle, width, cx),
-                    self.render_cta_button(4, "Install the CLI", install_cli::Install, width, cx),
+                    self.render_cta_button("Choose a theme", theme_selector::Toggle, width, cx),
+                    self.render_cta_button("Choose a keymap", ToggleBaseKeymapSelector, width, cx),
+                    self.render_cta_button("Install the CLI", install_cli::Install, width, cx),
                     self.render_settings_checkbox::<Metrics>(
                         "Do you want to send telemetry?",
                         &theme.welcome.checkbox,
@@ -110,7 +116,6 @@ impl WelcomePage {
 
     fn render_cta_button<L, A>(
         &self,
-        region_id: usize,
         label: L,
         action: A,
         width: f32,
@@ -121,7 +126,7 @@ impl WelcomePage {
         A: 'static + Action + Clone,
     {
         let theme = cx.global::<Settings>().theme.clone();
-        MouseEventHandler::<A>::new(region_id, cx, |state, _| {
+        MouseEventHandler::<A>::new(0, cx, |state, _| {
             let style = theme.welcome.button.style_for(state, false);
             Label::new(label, style.text.clone())
                 .aligned()

crates/zed/src/zed.rs 🔗

@@ -35,7 +35,7 @@ use std::{borrow::Cow, env, path::Path, str, sync::Arc};
 use util::{channel::ReleaseChannel, paths, ResultExt, StaffMode};
 use uuid::Uuid;
 pub use workspace;
-use workspace::{dock::Dock, open_new, sidebar::SidebarSide, AppState, Restart, Workspace};
+use workspace::{open_new, sidebar::SidebarSide, AppState, Restart, Workspace};
 
 pub const FIRST_OPEN: &str = "first_open";
 
@@ -270,7 +270,6 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
                 workspace.toggle_sidebar(SidebarSide::Left, cx);
                 let welcome_page = cx.add_view(|cx| welcome::WelcomePage::new(cx));
                 workspace.add_item_to_center(Box::new(welcome_page.clone()), cx);
-                Dock::move_dock(workspace, settings::DockAnchor::Bottom, false, cx);
                 cx.focus(welcome_page);
                 cx.notify();
             })