settings_ui: Add dropdown component + other fixes (#38909)

Ben Kunkle created

Closes #ISSUE

Release Notes:

- N/A *or* Added/Fixed/Improved ...

Change summary

Cargo.lock                                       |   2 
crates/settings/Cargo.toml                       |   9 
crates/settings/src/settings_content.rs          |   6 
crates/settings/src/settings_content/editor.rs   |  13 +
crates/settings/src/settings_content/terminal.rs |   1 
crates/settings/src/settings_store.rs            |  32 --
crates/settings_ui/Cargo.toml                    |   1 
crates/settings_ui/src/settings_ui.rs            | 185 ++++++++++++-----
8 files changed, 162 insertions(+), 87 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14462,6 +14462,7 @@ dependencies = [
  "serde_with",
  "settings_macros",
  "smallvec",
+ "strum 0.27.1",
  "tree-sitter",
  "tree-sitter-json",
  "unindent",
@@ -14522,6 +14523,7 @@ dependencies = [
  "serde",
  "session",
  "settings",
+ "strum 0.27.1",
  "theme",
  "ui",
  "util",

crates/settings/Cargo.toml 🔗

@@ -28,14 +28,15 @@ paths.workspace = true
 release_channel.workspace = true
 rust-embed.workspace = true
 schemars.workspace = true
-serde.workspace = true
-serde_json.workspace = true
-settings_macros = { path = "../settings_macros" }
 serde_json_lenient.workspace = true
-serde_repr.workspace = true
+serde_json.workspace = true
 serde_path_to_error.workspace = true
+serde_repr.workspace = true
 serde_with.workspace = true
+serde.workspace = true
+settings_macros = { path = "../settings_macros" }
 smallvec.workspace = true
+strum.workspace = true
 tree-sitter-json.workspace = true
 tree-sitter.workspace = true
 util.workspace = true

crates/settings/src/settings_content.rs 🔗

@@ -870,6 +870,12 @@ impl From<bool> for SaturatingBool {
     }
 }
 
+impl From<SaturatingBool> for bool {
+    fn from(value: SaturatingBool) -> bool {
+        value.0
+    }
+}
+
 impl merge_from::MergeFrom for SaturatingBool {
     fn merge_from(&mut self, other: &Self) {
         self.0 |= other.0

crates/settings/src/settings_content/editor.rs 🔗

@@ -485,7 +485,18 @@ pub enum ScrollBeyondLastLine {
 
 /// The shape of a selection cursor.
 #[derive(
-    Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom,
+    Copy,
+    Clone,
+    Debug,
+    Default,
+    Serialize,
+    Deserialize,
+    PartialEq,
+    Eq,
+    JsonSchema,
+    MergeFrom,
+    strum::VariantArray,
+    strum::VariantNames,
 )]
 #[serde(rename_all = "snake_case")]
 pub enum CursorShape {

crates/settings/src/settings_content/terminal.rs 🔗

@@ -217,6 +217,7 @@ pub enum ShowScrollbar {
     Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom,
 )]
 #[serde(rename_all = "snake_case")]
+// todo() -> combine with CursorShape
 pub enum CursorShapeContent {
     /// Cursor is a block like `█`.
     #[default]

crates/settings/src/settings_store.rs 🔗

@@ -7,7 +7,7 @@ use futures::{
     channel::{mpsc, oneshot},
     future::LocalBoxFuture,
 };
-use gpui::{App, AsyncApp, BorrowAppContext, Global, SharedString, Task, UpdateGlobal};
+use gpui::{App, AsyncApp, BorrowAppContext, Global, Task, UpdateGlobal};
 
 use paths::{EDITORCONFIG_NAME, local_settings_file_relative_path, task_file_name};
 use schemars::{JsonSchema, json_schema};
@@ -34,7 +34,7 @@ use crate::{
     ActiveSettingsProfileName, FontFamilyName, IconThemeName, LanguageSettingsContent,
     LanguageToSettingsMap, SettingsJsonSchemaParams, ThemeName, VsCodeSettings, WorktreeId,
     merge_from::MergeFrom,
-    parse_json_with_comments, replace_value_in_json_text,
+    parse_json_with_comments,
     settings_content::{
         ExtensionsSettingsContent, ProjectSettingsContent, SettingsContent, UserSettingsContent,
     },
@@ -439,34 +439,6 @@ impl SettingsStore {
         return rx;
     }
 
-    pub fn update_settings_file_at_path(
-        &self,
-        fs: Arc<dyn Fs>,
-        path: &[impl AsRef<str>],
-        new_value: serde_json::Value,
-    ) -> oneshot::Receiver<Result<()>> {
-        let key_path = path
-            .into_iter()
-            .map(AsRef::as_ref)
-            .map(SharedString::new)
-            .collect::<Vec<_>>();
-        let update = move |mut old_text: String, cx: AsyncApp| {
-            cx.read_global(|store: &SettingsStore, _cx| {
-                // todo(settings_ui) use `update_value_in_json_text` for merging new and old objects with comment preservation, needs old value though...
-                let (range, replacement) = replace_value_in_json_text(
-                    &old_text,
-                    key_path.as_slice(),
-                    store.json_tab_size(),
-                    Some(&new_value),
-                    None,
-                );
-                old_text.replace_range(range, &replacement);
-                old_text
-            })
-        };
-        self.update_settings_file_inner(fs, update)
-    }
-
     pub fn update_settings_file(
         &self,
         fs: Arc<dyn Fs>,

crates/settings_ui/Cargo.toml 🔗

@@ -26,6 +26,7 @@ gpui.workspace = true
 menu.workspace = true
 serde.workspace = true
 settings.workspace = true
+strum.workspace = true
 theme.workspace = true
 ui.workspace = true
 util.workspace = true

crates/settings_ui/src/settings_ui.rs 🔗

@@ -1,5 +1,5 @@
 //! # settings_ui
-use std::{rc::Rc, sync::Arc};
+use std::sync::Arc;
 
 use editor::Editor;
 use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
@@ -11,9 +11,9 @@ use project::WorktreeId;
 use settings::{SettingsContent, SettingsStore};
 use ui::{
     ActiveTheme as _, AnyElement, BorrowAppContext as _, Button, Clickable as _, Color,
-    FluentBuilder as _, Icon, IconName, InteractiveElement as _, Label, LabelCommon as _,
-    LabelSize, ParentElement, SharedString, StatefulInteractiveElement as _, Styled, Switch,
-    v_flex,
+    DropdownMenu, FluentBuilder as _, Icon, IconName, InteractiveElement as _, Label,
+    LabelCommon as _, LabelSize, ParentElement, SharedString, StatefulInteractiveElement as _,
+    Styled, Switch, v_flex,
 };
 use util::{paths::PathStyle, rel_path::RelPath};
 
@@ -22,30 +22,24 @@ fn user_settings_data() -> Vec<SettingsPage> {
         SettingsPage {
             title: "General Page",
             items: vec![
-                SettingsPageItem::SectionHeader("General Section"),
+                SettingsPageItem::SectionHeader("General"),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Confirm Quit",
                     description: "Whether to confirm before quitting Zed",
-                    render: Rc::new(|_, cx| {
-                        render_toggle_button(
-                            "confirm_quit",
-                            SettingsFile::User,
-                            cx,
-                            |settings_content| &mut settings_content.workspace.confirm_quit,
-                        )
-                    }),
+                    render: |file, _, cx| {
+                        render_toggle_button("confirm_quit", file, cx, |settings_content| {
+                            &mut settings_content.workspace.confirm_quit
+                        })
+                    },
                 }),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Auto Update",
                     description: "Automatically update Zed (may be ignored on Linux if installed through a package manager)",
-                    render: Rc::new(|_, cx| {
-                        render_toggle_button(
-                            "Auto Update",
-                            SettingsFile::User,
-                            cx,
-                            |settings_content| &mut settings_content.auto_update,
-                        )
-                    }),
+                    render: |file, _, cx| {
+                        render_toggle_button("Auto Update", file, cx, |settings_content| {
+                            &mut settings_content.auto_update
+                        })
+                    },
                 }),
             ],
         },
@@ -56,15 +50,45 @@ fn user_settings_data() -> Vec<SettingsPage> {
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Project Name",
                     description: "The displayed name of this project. If not set, the root directory name",
-                    render: Rc::new(|window, cx| {
-                        render_text_field(
-                            "project_name",
-                            SettingsFile::User,
+                    render: |file, window, cx| {
+                        render_text_field("project_name", file, window, cx, |settings_content| {
+                            &mut settings_content.project.worktree.project_name
+                        })
+                    },
+                }),
+            ],
+        },
+        SettingsPage {
+            title: "AI",
+            items: vec![
+                SettingsPageItem::SectionHeader("General"),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Disable AI",
+                    description: "Whether to disable all AI features in Zed",
+                    render: |file, _, cx| {
+                        render_toggle_button("disable_AI", file, cx, |settings_content| {
+                            &mut settings_content.disable_ai
+                        })
+                    },
+                }),
+            ],
+        },
+        SettingsPage {
+            title: "Appearance & Behavior",
+            items: vec![
+                SettingsPageItem::SectionHeader("Cursor"),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Cursor Shape",
+                    description: "Cursor shape for the editor",
+                    render: |file, window, cx| {
+                        render_dropdown::<settings::CursorShape>(
+                            "cursor_shape",
+                            file,
                             window,
                             cx,
-                            |settings_content| &mut settings_content.project.worktree.project_name,
+                            |settings_content| &mut settings_content.editor.cursor_shape,
                         )
-                    }),
+                    },
                 }),
             ],
         },
@@ -79,18 +103,11 @@ fn project_settings_data() -> Vec<SettingsPage> {
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Project Name",
                 description: " The displayed name of this project. If not set, the root directory name",
-                render: Rc::new(|window, cx| {
-                    render_text_field(
-                        "project_name",
-                        SettingsFile::Local((
-                            WorktreeId::from_usize(0),
-                            Arc::from(RelPath::new("TODO: actually pass through file").unwrap()),
-                        )),
-                        window,
-                        cx,
-                        |settings_content| &mut settings_content.project.worktree.project_name,
-                    )
-                }),
+                render: |file, window, cx| {
+                    render_text_field("project_name", file, window, cx, |settings_content| {
+                        &mut settings_content.project.worktree.project_name
+                    })
+                },
             }),
         ],
     }]
@@ -169,7 +186,7 @@ enum SettingsPageItem {
 }
 
 impl SettingsPageItem {
-    fn render(&self, window: &mut Window, cx: &mut App) -> AnyElement {
+    fn render(&self, file: SettingsFile, window: &mut Window, cx: &mut App) -> AnyElement {
         match self {
             SettingsPageItem::SectionHeader(header) => Label::new(SharedString::new_static(header))
                 .size(LabelSize::Large)
@@ -177,7 +194,7 @@ impl SettingsPageItem {
             SettingsPageItem::SettingItem(setting_item) => div()
                 .child(setting_item.title)
                 .child(setting_item.description)
-                .child((setting_item.render)(window, cx))
+                .child((setting_item.render)(file, window, cx))
                 .into_any_element(),
         }
     }
@@ -196,7 +213,7 @@ impl SettingsPageItem {
 struct SettingItem {
     title: &'static str,
     description: &'static str,
-    render: std::rc::Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>,
+    render: fn(file: SettingsFile, &mut Window, &mut App) -> AnyElement,
 }
 
 #[allow(unused)]
@@ -345,7 +362,11 @@ impl SettingsWindow {
         div()
             .child(self.render_files(window, cx))
             .child(Label::new(page.title))
-            .children(page.items.iter().map(|item| item.render(window, cx)))
+            .children(
+                page.items
+                    .iter()
+                    .map(|item| item.render(self.current_file.clone(), window, cx)),
+            )
     }
 
     fn current_page(&self) -> &SettingsPage {
@@ -440,11 +461,11 @@ fn render_text_field(
         .into_any_element()
 }
 
-fn render_toggle_button(
+fn render_toggle_button<B: Into<bool> + From<bool> + Copy + Send + 'static>(
     id: &'static str,
     _: SettingsFile,
     cx: &mut App,
-    get_value: fn(&mut SettingsContent) -> &mut Option<bool>,
+    get_value: fn(&mut SettingsContent) -> &mut Option<B>,
 ) -> AnyElement {
     // TODO: in settings window state
     let store = SettingsStore::global(cx);
@@ -457,18 +478,78 @@ fn render_toggle_button(
         .unwrap_or_default()
         .content;
 
-    let toggle_state =
-        if get_value(&mut user_settings).unwrap_or_else(|| get_value(&mut defaults).unwrap()) {
-            ui::ToggleState::Selected
-        } else {
-            ui::ToggleState::Unselected
-        };
+    let toggle_state = if get_value(&mut user_settings)
+        .unwrap_or_else(|| get_value(&mut defaults).unwrap())
+        .into()
+    {
+        ui::ToggleState::Selected
+    } else {
+        ui::ToggleState::Unselected
+    };
 
     Switch::new(id, toggle_state)
         .on_click({
             move |state, _window, cx| {
-                write_setting_value(get_value, Some(*state == ui::ToggleState::Selected), cx);
+                write_setting_value(
+                    get_value,
+                    Some((*state == ui::ToggleState::Selected).into()),
+                    cx,
+                );
             }
         })
         .into_any_element()
 }
+
+fn render_dropdown<T>(
+    id: &'static str,
+    _: SettingsFile,
+    window: &mut Window,
+    cx: &mut App,
+    get_value: fn(&mut SettingsContent) -> &mut Option<T>,
+) -> AnyElement
+where
+    T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + 'static,
+{
+    let variants = || -> &'static [T] { <T as strum::VariantArray>::VARIANTS };
+    let labels = || -> &'static [&'static str] { <T as strum::VariantNames>::VARIANTS };
+
+    let store = SettingsStore::global(cx);
+    let mut defaults = store.raw_default_settings().clone();
+    let mut user_settings = store
+        .raw_user_settings()
+        .cloned()
+        .unwrap_or_default()
+        .content;
+
+    let current_value =
+        get_value(&mut user_settings).unwrap_or_else(|| get_value(&mut defaults).unwrap());
+    let current_value_label =
+        labels()[variants().iter().position(|v| *v == current_value).unwrap()];
+
+    DropdownMenu::new(
+        id,
+        current_value_label,
+        ui::ContextMenu::build(window, cx, move |mut menu, _, _| {
+            for (value, label) in variants()
+                .into_iter()
+                .copied()
+                .zip(labels().into_iter().copied())
+            {
+                menu = menu.toggleable_entry(
+                    label,
+                    value == current_value,
+                    ui::IconPosition::Start,
+                    None,
+                    move |_, cx| {
+                        if value == current_value {
+                            return;
+                        }
+                        write_setting_value(get_value, Some(value), cx);
+                    },
+                );
+            }
+            menu
+        }),
+    )
+    .into_any_element()
+}