settings_ui: Add UI and buffer font weight controls (#15104)

Marshall Bowers created

This PR adds settings controls for the UI and buffer font weight
settings.

It also does some work around grouping the settings into related
sections.

Release Notes:

- N/A

Change summary

Cargo.lock                                        |   3 
crates/editor/src/editor.rs                       |   2 
crates/editor/src/editor_settings_controls.rs     | 167 ++++++++++++++
crates/gpui/src/text_system.rs                    |  13 +
crates/settings/src/editable_setting_control.rs   |  33 ++
crates/settings/src/settings.rs                   |   2 
crates/settings_ui/Cargo.toml                     |   3 
crates/settings_ui/src/settings_ui.rs             |  28 +
crates/settings_ui/src/theme_settings_controls.rs | 112 ++++++++++
crates/settings_ui/src/theme_settings_ui.rs       | 190 -----------------
crates/ui/src/components.rs                       |   6 
crates/ui/src/components/dropdown_menu.rs         | 138 +++++++++---
crates/ui/src/components/setting.rs               |  41 ++-
crates/ui/src/components/settings_container.rs    |  33 ++
crates/ui/src/components/settings_group.rs        |  36 +++
crates/ui/src/components/stories/setting.rs       |  22 
16 files changed, 557 insertions(+), 272 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -9585,10 +9585,9 @@ name = "settings_ui"
 version = "0.1.0"
 dependencies = [
  "command_palette_hooks",
+ "editor",
  "feature_flags",
- "fs",
  "gpui",
- "project",
  "settings",
  "theme",
  "ui",

crates/editor/src/editor.rs 🔗

@@ -18,6 +18,7 @@ mod blink_manager;
 mod debounced_delay;
 pub mod display_map;
 mod editor_settings;
+mod editor_settings_controls;
 mod element;
 mod git;
 mod highlight_matching_bracket;
@@ -57,6 +58,7 @@ use debounced_delay::DebouncedDelay;
 use display_map::*;
 pub use display_map::{DisplayPoint, FoldPlaceholder};
 pub use editor_settings::{CurrentLineHighlight, EditorSettings};
+pub use editor_settings_controls::*;
 use element::LineWithInvisibles;
 pub use element::{
     CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition,

crates/editor/src/editor_settings_controls.rs 🔗

@@ -0,0 +1,167 @@
+use gpui::{AppContext, FontWeight};
+use project::project_settings::{InlineBlameSettings, ProjectSettings};
+use settings::{EditableSettingControl, Settings};
+use theme::ThemeSettings;
+use ui::{
+    prelude::*, CheckboxWithLabel, ContextMenu, DropdownMenu, NumericStepper, SettingsContainer,
+    SettingsGroup,
+};
+
+#[derive(IntoElement)]
+pub struct EditorSettingsControls {}
+
+impl EditorSettingsControls {
+    pub fn new() -> Self {
+        Self {}
+    }
+}
+
+impl RenderOnce for EditorSettingsControls {
+    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
+        SettingsContainer::new()
+            .child(
+                SettingsGroup::new("Font")
+                    .child(BufferFontSizeControl)
+                    .child(BufferFontWeightControl),
+            )
+            .child(SettingsGroup::new("Editor").child(InlineGitBlameControl))
+    }
+}
+
+#[derive(IntoElement)]
+struct BufferFontSizeControl;
+
+impl EditableSettingControl for BufferFontSizeControl {
+    type Value = Pixels;
+    type Settings = ThemeSettings;
+
+    fn name(&self) -> SharedString {
+        "Buffer Font Size".into()
+    }
+
+    fn read(cx: &AppContext) -> Self::Value {
+        let settings = ThemeSettings::get_global(cx);
+        settings.buffer_font_size
+    }
+
+    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value) {
+        settings.buffer_font_size = Some(value.into());
+    }
+}
+
+impl RenderOnce for BufferFontSizeControl {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let value = Self::read(cx);
+
+        h_flex()
+            .gap_2()
+            .child(Icon::new(IconName::FontSize))
+            .child(NumericStepper::new(
+                value.to_string(),
+                move |_, cx| {
+                    Self::write(value - px(1.), cx);
+                },
+                move |_, cx| {
+                    Self::write(value + px(1.), cx);
+                },
+            ))
+    }
+}
+
+#[derive(IntoElement)]
+struct BufferFontWeightControl;
+
+impl EditableSettingControl for BufferFontWeightControl {
+    type Value = FontWeight;
+    type Settings = ThemeSettings;
+
+    fn name(&self) -> SharedString {
+        "Buffer Font Weight".into()
+    }
+
+    fn read(cx: &AppContext) -> Self::Value {
+        let settings = ThemeSettings::get_global(cx);
+        settings.buffer_font.weight
+    }
+
+    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value) {
+        settings.buffer_font_weight = Some(value.0);
+    }
+}
+
+impl RenderOnce for BufferFontWeightControl {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let value = Self::read(cx);
+
+        h_flex()
+            .gap_2()
+            .child(Icon::new(IconName::FontWeight))
+            .child(DropdownMenu::new(
+                "buffer-font-weight",
+                value.0.to_string(),
+                ContextMenu::build(cx, |mut menu, _cx| {
+                    for weight in FontWeight::ALL {
+                        menu = menu.custom_entry(
+                            move |_cx| Label::new(weight.0.to_string()).into_any_element(),
+                            {
+                                move |cx| {
+                                    Self::write(weight, cx);
+                                }
+                            },
+                        )
+                    }
+
+                    menu
+                }),
+            ))
+    }
+}
+
+#[derive(IntoElement)]
+struct InlineGitBlameControl;
+
+impl EditableSettingControl for InlineGitBlameControl {
+    type Value = bool;
+    type Settings = ProjectSettings;
+
+    fn name(&self) -> SharedString {
+        "Inline Git Blame".into()
+    }
+
+    fn read(cx: &AppContext) -> Self::Value {
+        let settings = ProjectSettings::get_global(cx);
+        settings.git.inline_blame_enabled()
+    }
+
+    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value) {
+        if let Some(inline_blame) = settings.git.inline_blame.as_mut() {
+            inline_blame.enabled = value;
+        } else {
+            settings.git.inline_blame = Some(InlineBlameSettings {
+                enabled: false,
+                ..Default::default()
+            });
+        }
+    }
+}
+
+impl RenderOnce for InlineGitBlameControl {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let value = Self::read(cx);
+
+        CheckboxWithLabel::new(
+            "inline-git-blame",
+            Label::new(self.name()),
+            value.into(),
+            |selection, cx| {
+                Self::write(
+                    match selection {
+                        Selection::Selected => true,
+                        Selection::Unselected | Selection::Indeterminate => false,
+                    },
+                    cx,
+                );
+            },
+        )
+    }
+}

crates/gpui/src/text_system.rs 🔗

@@ -595,6 +595,19 @@ impl FontWeight {
     pub const EXTRA_BOLD: FontWeight = FontWeight(800.0);
     /// Black weight (900), the thickest value.
     pub const BLACK: FontWeight = FontWeight(900.0);
+
+    /// All of the font weights, in order from thinnest to thickest.
+    pub const ALL: [FontWeight; 9] = [
+        Self::THIN,
+        Self::EXTRA_LIGHT,
+        Self::LIGHT,
+        Self::NORMAL,
+        Self::MEDIUM,
+        Self::SEMIBOLD,
+        Self::BOLD,
+        Self::EXTRA_BOLD,
+        Self::BLACK,
+    ];
 }
 
 /// Allows italic or oblique faces to be selected.

crates/settings/src/editable_setting_control.rs 🔗

@@ -0,0 +1,33 @@
+use fs::Fs;
+use gpui::{AppContext, RenderOnce, SharedString};
+
+use crate::{update_settings_file, Settings};
+
+/// A UI control that can be used to edit a setting.
+pub trait EditableSettingControl: RenderOnce {
+    /// The type of the setting value.
+    type Value: Send;
+
+    /// The settings type to which this setting belongs.
+    type Settings: Settings;
+
+    /// Returns the name of this setting.
+    fn name(&self) -> SharedString;
+
+    /// Reads the setting value from the settings.
+    fn read(cx: &AppContext) -> Self::Value;
+
+    /// Applies the given setting file to the settings file contents.
+    ///
+    /// This will be called when writing the setting value back to the settings file.
+    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value);
+
+    /// Writes the given setting value to the settings files.
+    fn write(value: Self::Value, cx: &AppContext) {
+        let fs = <dyn Fs>::global(cx);
+
+        update_settings_file::<Self::Settings>(fs, cx, move |settings, _cx| {
+            Self::apply(settings, value);
+        });
+    }
+}

crates/settings/src/settings.rs 🔗

@@ -1,3 +1,4 @@
+mod editable_setting_control;
 mod keymap_file;
 mod settings_file;
 mod settings_store;
@@ -7,6 +8,7 @@ use rust_embed::RustEmbed;
 use std::{borrow::Cow, str};
 use util::asset_str;
 
+pub use editable_setting_control::*;
 pub use keymap_file::KeymapFile;
 pub use settings_file::*;
 pub use settings_store::{

crates/settings_ui/Cargo.toml 🔗

@@ -13,10 +13,9 @@ path = "src/settings_ui.rs"
 
 [dependencies]
 command_palette_hooks.workspace = true
+editor.workspace = true
 feature_flags.workspace = true
-fs.workspace = true
 gpui.workspace = true
-project.workspace = true
 settings.workspace = true
 theme.workspace = true
 ui.workspace = true

crates/settings_ui/src/settings_ui.rs 🔗

@@ -1,17 +1,16 @@
-mod theme_settings_ui;
+mod theme_settings_controls;
 
 use std::any::TypeId;
 
 use command_palette_hooks::CommandPaletteFilter;
+use editor::EditorSettingsControls;
 use feature_flags::{FeatureFlag, FeatureFlagViewExt};
 use gpui::{actions, AppContext, EventEmitter, FocusHandle, FocusableView, View};
 use ui::prelude::*;
 use workspace::item::{Item, ItemEvent};
 use workspace::Workspace;
 
-use crate::theme_settings_ui::{
-    BufferFontSizeSetting, EditableSetting, InlineGitBlameSetting, UiFontSizeSetting,
-};
+use crate::theme_settings_controls::ThemeSettingsControls;
 
 pub struct SettingsUiFeatureFlag;
 
@@ -101,16 +100,23 @@ impl Item for SettingsPage {
 }
 
 impl Render for SettingsPage {
-    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
         v_flex()
             .p_4()
             .size_full()
+            .gap_4()
             .child(Label::new("Settings").size(LabelSize::Large))
-            .child(Label::new(
-                "Nothing to see here yet. Feature-flagged for staff.",
-            ))
-            .child(UiFontSizeSetting::new(cx))
-            .child(BufferFontSizeSetting::new(cx))
-            .child(InlineGitBlameSetting::new(cx))
+            .child(
+                v_flex()
+                    .gap_1()
+                    .child(Label::new("Appearance"))
+                    .child(ThemeSettingsControls::new()),
+            )
+            .child(
+                v_flex()
+                    .gap_1()
+                    .child(Label::new("Editor"))
+                    .child(EditorSettingsControls::new()),
+            )
     }
 }

crates/settings_ui/src/theme_settings_controls.rs 🔗

@@ -0,0 +1,112 @@
+use gpui::{AppContext, FontWeight};
+use settings::{EditableSettingControl, Settings};
+use theme::ThemeSettings;
+use ui::{prelude::*, ContextMenu, DropdownMenu, NumericStepper, SettingsContainer, SettingsGroup};
+
+#[derive(IntoElement)]
+pub struct ThemeSettingsControls {}
+
+impl ThemeSettingsControls {
+    pub fn new() -> Self {
+        Self {}
+    }
+}
+
+impl RenderOnce for ThemeSettingsControls {
+    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
+        SettingsContainer::new().child(
+            SettingsGroup::new("Font")
+                .child(UiFontSizeControl)
+                .child(UiFontWeightControl),
+        )
+    }
+}
+
+#[derive(IntoElement)]
+struct UiFontSizeControl;
+
+impl EditableSettingControl for UiFontSizeControl {
+    type Value = Pixels;
+    type Settings = ThemeSettings;
+
+    fn name(&self) -> SharedString {
+        "UI Font Size".into()
+    }
+
+    fn read(cx: &AppContext) -> Self::Value {
+        let settings = ThemeSettings::get_global(cx);
+        settings.ui_font_size
+    }
+
+    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value) {
+        settings.ui_font_size = Some(value.into());
+    }
+}
+
+impl RenderOnce for UiFontSizeControl {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let value = Self::read(cx);
+
+        h_flex()
+            .gap_2()
+            .child(Icon::new(IconName::FontSize))
+            .child(NumericStepper::new(
+                value.to_string(),
+                move |_, cx| {
+                    Self::write(value - px(1.), cx);
+                },
+                move |_, cx| {
+                    Self::write(value + px(1.), cx);
+                },
+            ))
+    }
+}
+
+#[derive(IntoElement)]
+struct UiFontWeightControl;
+
+impl EditableSettingControl for UiFontWeightControl {
+    type Value = FontWeight;
+    type Settings = ThemeSettings;
+
+    fn name(&self) -> SharedString {
+        "UI Font Weight".into()
+    }
+
+    fn read(cx: &AppContext) -> Self::Value {
+        let settings = ThemeSettings::get_global(cx);
+        settings.ui_font.weight
+    }
+
+    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value) {
+        settings.ui_font_weight = Some(value.0);
+    }
+}
+
+impl RenderOnce for UiFontWeightControl {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let value = Self::read(cx);
+
+        h_flex()
+            .gap_2()
+            .child(Icon::new(IconName::FontWeight))
+            .child(DropdownMenu::new(
+                "ui-font-weight",
+                value.0.to_string(),
+                ContextMenu::build(cx, |mut menu, _cx| {
+                    for weight in FontWeight::ALL {
+                        menu = menu.custom_entry(
+                            move |_cx| Label::new(weight.0.to_string()).into_any_element(),
+                            {
+                                move |cx| {
+                                    Self::write(weight, cx);
+                                }
+                            },
+                        )
+                    }
+
+                    menu
+                }),
+            ))
+    }
+}

crates/settings_ui/src/theme_settings_ui.rs 🔗

@@ -1,190 +0,0 @@
-use fs::Fs;
-use gpui::AppContext;
-use project::project_settings::{InlineBlameSettings, ProjectSettings};
-use settings::{update_settings_file, Settings};
-use theme::ThemeSettings;
-use ui::{prelude::*, CheckboxWithLabel, NumericStepper};
-
-pub trait EditableSetting: RenderOnce {
-    /// The type of the setting value.
-    type Value: Send;
-
-    /// The settings type to which this setting belongs.
-    type Settings: Settings;
-
-    /// Returns the name of this setting.
-    fn name(&self) -> SharedString;
-
-    /// Returns the icon to be displayed in place of the setting name.
-    fn icon(&self) -> Option<IconName> {
-        None
-    }
-
-    /// Returns a new instance of this setting.
-    fn new(cx: &AppContext) -> Self;
-
-    /// Applies the given setting file to the settings file contents.
-    ///
-    /// This will be called when writing the setting value back to the settings file.
-    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value);
-
-    /// Writes the given setting value to the settings files.
-    fn write(value: Self::Value, cx: &AppContext) {
-        let fs = <dyn Fs>::global(cx);
-
-        update_settings_file::<Self::Settings>(fs, cx, move |settings, _cx| {
-            Self::apply(settings, value);
-        });
-    }
-}
-
-#[derive(IntoElement)]
-pub struct UiFontSizeSetting(Pixels);
-
-impl EditableSetting for UiFontSizeSetting {
-    type Value = Pixels;
-    type Settings = ThemeSettings;
-
-    fn name(&self) -> SharedString {
-        "UI Font Size".into()
-    }
-
-    fn icon(&self) -> Option<IconName> {
-        Some(IconName::FontSize)
-    }
-
-    fn new(cx: &AppContext) -> Self {
-        let settings = ThemeSettings::get_global(cx);
-
-        Self(settings.ui_font_size)
-    }
-
-    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value) {
-        settings.ui_font_size = Some(value.into());
-    }
-}
-
-impl RenderOnce for UiFontSizeSetting {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        let value = self.0;
-
-        h_flex()
-            .gap_2()
-            .map(|el| {
-                if let Some(icon) = self.icon() {
-                    el.child(Icon::new(icon))
-                } else {
-                    el.child(Label::new(self.name()))
-                }
-            })
-            .child(NumericStepper::new(
-                self.0.to_string(),
-                move |_, cx| {
-                    Self::write(value - px(1.), cx);
-                },
-                move |_, cx| {
-                    Self::write(value + px(1.), cx);
-                },
-            ))
-    }
-}
-
-#[derive(IntoElement)]
-pub struct BufferFontSizeSetting(Pixels);
-
-impl EditableSetting for BufferFontSizeSetting {
-    type Value = Pixels;
-    type Settings = ThemeSettings;
-
-    fn name(&self) -> SharedString {
-        "Buffer Font Size".into()
-    }
-
-    fn icon(&self) -> Option<IconName> {
-        Some(IconName::FontSize)
-    }
-
-    fn new(cx: &AppContext) -> Self {
-        let settings = ThemeSettings::get_global(cx);
-
-        Self(settings.buffer_font_size)
-    }
-
-    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value) {
-        settings.buffer_font_size = Some(value.into());
-    }
-}
-
-impl RenderOnce for BufferFontSizeSetting {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        let value = self.0;
-
-        h_flex()
-            .gap_2()
-            .map(|el| {
-                if let Some(icon) = self.icon() {
-                    el.child(Icon::new(icon))
-                } else {
-                    el.child(Label::new(self.name()))
-                }
-            })
-            .child(NumericStepper::new(
-                self.0.to_string(),
-                move |_, cx| {
-                    Self::write(value - px(1.), cx);
-                },
-                move |_, cx| {
-                    Self::write(value + px(1.), cx);
-                },
-            ))
-    }
-}
-
-#[derive(IntoElement)]
-pub struct InlineGitBlameSetting(bool);
-
-impl EditableSetting for InlineGitBlameSetting {
-    type Value = bool;
-    type Settings = ProjectSettings;
-
-    fn name(&self) -> SharedString {
-        "Inline Git Blame".into()
-    }
-
-    fn new(cx: &AppContext) -> Self {
-        let settings = ProjectSettings::get_global(cx);
-        Self(settings.git.inline_blame_enabled())
-    }
-
-    fn apply(settings: &mut <Self::Settings as Settings>::FileContent, value: Self::Value) {
-        if let Some(inline_blame) = settings.git.inline_blame.as_mut() {
-            inline_blame.enabled = value;
-        } else {
-            settings.git.inline_blame = Some(InlineBlameSettings {
-                enabled: false,
-                ..Default::default()
-            });
-        }
-    }
-}
-
-impl RenderOnce for InlineGitBlameSetting {
-    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
-        let value = self.0;
-
-        CheckboxWithLabel::new(
-            "inline-git-blame",
-            Label::new(self.name()),
-            value.into(),
-            |selection, cx| {
-                Self::write(
-                    match selection {
-                        Selection::Selected => true,
-                        Selection::Unselected | Selection::Indeterminate => false,
-                    },
-                    cx,
-                );
-            },
-        )
-    }
-}

crates/ui/src/components.rs 🔗

@@ -18,6 +18,8 @@ mod popover_menu;
 mod radio;
 mod right_click_menu;
 mod setting;
+mod settings_container;
+mod settings_group;
 mod stack;
 mod tab;
 mod tab_bar;
@@ -33,7 +35,7 @@ pub use checkbox::*;
 pub use context_menu::*;
 pub use disclosure::*;
 pub use divider::*;
-use dropdown_menu::*;
+pub use dropdown_menu::*;
 pub use facepile::*;
 pub use icon::*;
 pub use indicator::*;
@@ -47,6 +49,8 @@ pub use popover_menu::*;
 pub use radio::*;
 pub use right_click_menu::*;
 pub use setting::*;
+pub use settings_container::*;
+pub use settings_group::*;
 pub use stack::*;
 pub use tab::*;
 pub use tab_bar::*;

crates/ui/src/components/dropdown_menu.rs 🔗

@@ -1,52 +1,107 @@
-use crate::prelude::*;
+use gpui::{ClickEvent, CursorStyle, MouseButton, View};
 
-/// !!don't use this yet – it's not functional!!
-///
-/// pub crate until this is functional
-///
-/// just a placeholder for now for filling out the settings menu stories.
-#[derive(Debug, Clone, IntoElement)]
-pub(crate) struct DropdownMenu {
-    pub id: ElementId,
-    current_item: Option<SharedString>,
-    // items: Vec<SharedString>,
+use crate::{prelude::*, ContextMenu, PopoverMenu};
+
+#[derive(IntoElement)]
+pub struct DropdownMenu {
+    id: ElementId,
+    label: SharedString,
+    menu: View<ContextMenu>,
     full_width: bool,
     disabled: bool,
 }
 
 impl DropdownMenu {
-    pub fn new(id: impl Into<ElementId>, _cx: &WindowContext) -> Self {
+    pub fn new(
+        id: impl Into<ElementId>,
+        label: impl Into<SharedString>,
+        menu: View<ContextMenu>,
+    ) -> Self {
         Self {
             id: id.into(),
-            current_item: None,
-            // items: Vec::new(),
+            label: label.into(),
+            menu,
             full_width: false,
             disabled: false,
         }
     }
 
-    pub fn current_item(mut self, current_item: Option<SharedString>) -> Self {
-        self.current_item = current_item;
-        self
-    }
-
     pub fn full_width(mut self, full_width: bool) -> Self {
         self.full_width = full_width;
         self
     }
+}
 
-    pub fn disabled(mut self, disabled: bool) -> Self {
+impl Disableable for DropdownMenu {
+    fn disabled(mut self, disabled: bool) -> Self {
         self.disabled = disabled;
         self
     }
 }
 
 impl RenderOnce for DropdownMenu {
+    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
+        PopoverMenu::new(self.id)
+            .menu(move |_cx| Some(self.menu.clone()))
+            .trigger(DropdownMenuTrigger::new(self.label))
+    }
+}
+
+#[derive(IntoElement)]
+struct DropdownMenuTrigger {
+    label: SharedString,
+    full_width: bool,
+    selected: bool,
+    disabled: bool,
+    cursor_style: CursorStyle,
+    on_click: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
+}
+
+impl DropdownMenuTrigger {
+    pub fn new(label: impl Into<SharedString>) -> Self {
+        Self {
+            label: label.into(),
+            full_width: false,
+            selected: false,
+            disabled: false,
+            cursor_style: CursorStyle::default(),
+            on_click: None,
+        }
+    }
+}
+
+impl Disableable for DropdownMenuTrigger {
+    fn disabled(mut self, disabled: bool) -> Self {
+        self.disabled = disabled;
+        self
+    }
+}
+
+impl Selectable for DropdownMenuTrigger {
+    fn selected(mut self, selected: bool) -> Self {
+        self.selected = selected;
+        self
+    }
+}
+
+impl Clickable for DropdownMenuTrigger {
+    fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self {
+        self.on_click = Some(Box::new(handler));
+        self
+    }
+
+    fn cursor_style(mut self, cursor_style: CursorStyle) -> Self {
+        self.cursor_style = cursor_style;
+        self
+    }
+}
+
+impl RenderOnce for DropdownMenuTrigger {
     fn render(self, cx: &mut WindowContext) -> impl IntoElement {
         let disabled = self.disabled;
 
         h_flex()
-            .id(self.id)
+            .id("dropdown-menu-trigger")
             .justify_between()
             .rounded_md()
             .bg(cx.theme().colors().editor_background)
@@ -55,23 +110,25 @@ impl RenderOnce for DropdownMenu {
             .py_0p5()
             .gap_2()
             .min_w_20()
-            .when_else(
-                self.full_width,
-                |full_width| full_width.w_full(),
-                |auto_width| auto_width.flex_none().w_auto(),
-            )
-            .when_else(
-                disabled,
-                |disabled| disabled.cursor_not_allowed(),
-                |enabled| enabled.cursor_pointer(),
-            )
-            .child(
-                Label::new(self.current_item.unwrap_or("".into())).color(if disabled {
-                    Color::Disabled
+            .map(|el| {
+                if self.full_width {
+                    el.w_full()
                 } else {
-                    Color::Default
-                }),
-            )
+                    el.flex_none().w_auto()
+                }
+            })
+            .map(|el| {
+                if disabled {
+                    el.cursor_not_allowed()
+                } else {
+                    el.cursor_pointer()
+                }
+            })
+            .child(Label::new(self.label).color(if disabled {
+                Color::Disabled
+            } else {
+                Color::Default
+            }))
             .child(
                 Icon::new(IconName::ChevronUpDown)
                     .size(IconSize::XSmall)
@@ -81,5 +138,12 @@ impl RenderOnce for DropdownMenu {
                         Color::Muted
                     }),
             )
+            .when_some(self.on_click.filter(|_| !disabled), |el, on_click| {
+                el.on_mouse_down(MouseButton::Left, |_, cx| cx.prevent_default())
+                    .on_click(move |event, cx| {
+                        cx.stop_propagation();
+                        (on_click)(event, cx)
+                    })
+            })
     }
 }

crates/ui/src/components/setting.rs 🔗

@@ -1,4 +1,4 @@
-use crate::{prelude::*, Checkbox, ListHeader};
+use crate::{prelude::*, Checkbox, ContextMenu, ListHeader};
 
 use super::DropdownMenu;
 
@@ -42,12 +42,12 @@ pub enum SettingType {
 }
 
 #[derive(Debug, Clone, IntoElement)]
-pub struct SettingsGroup {
+pub struct LegacySettingsGroup {
     pub name: String,
     settings: Vec<SettingsItem>,
 }
 
-impl SettingsGroup {
+impl LegacySettingsGroup {
     pub fn new(name: impl Into<String>) -> Self {
         Self {
             name: name.into(),
@@ -61,7 +61,7 @@ impl SettingsGroup {
     }
 }
 
-impl RenderOnce for SettingsGroup {
+impl RenderOnce for LegacySettingsGroup {
     fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
         let empty_message = format!("No settings available for {}", self.name);
 
@@ -207,7 +207,7 @@ impl RenderOnce for SettingsItem {
         let id: ElementId = self.id.clone().into();
 
         // When the setting is disabled or toggled off, we don't want any secondary elements to be interactable
-        let secondary_element_disabled = self.disabled || self.toggled == Some(false);
+        let _secondary_element_disabled = self.disabled || self.toggled == Some(false);
 
         let full_width = match self.layout {
             SettingLayout::FullLine | SettingLayout::FullLineJustified => true,
@@ -239,10 +239,12 @@ impl RenderOnce for SettingsItem {
             SettingType::Toggle(_) => None,
             SettingType::ToggleAnd(secondary_setting_type) => match secondary_setting_type {
                 SecondarySettingType::Dropdown => Some(
-                    DropdownMenu::new(id.clone(), &cx)
-                        .current_item(current_string)
-                        .disabled(secondary_element_disabled)
-                        .into_any_element(),
+                    DropdownMenu::new(
+                        id.clone(),
+                        current_string.unwrap_or_default(),
+                        ContextMenu::build(cx, |menu, _cx| menu),
+                    )
+                    .into_any_element(),
                 ),
             },
             SettingType::Input(input_type) => match input_type {
@@ -250,10 +252,13 @@ impl RenderOnce for SettingsItem {
                 InputType::Number => Some(div().child("number").into_any_element()),
             },
             SettingType::Dropdown => Some(
-                DropdownMenu::new(id.clone(), &cx)
-                    .current_item(current_string)
-                    .full_width(true)
-                    .into_any_element(),
+                DropdownMenu::new(
+                    id.clone(),
+                    current_string.unwrap_or_default(),
+                    ContextMenu::build(cx, |menu, _cx| menu),
+                )
+                .full_width(true)
+                .into_any_element(),
             ),
             SettingType::Range => Some(div().child("range").into_any_element()),
             SettingType::Unsupported => None,
@@ -308,12 +313,12 @@ impl RenderOnce for SettingsItem {
     }
 }
 
-pub struct SettingsMenu {
+pub struct LegacySettingsMenu {
     name: SharedString,
-    groups: Vec<SettingsGroup>,
+    groups: Vec<LegacySettingsGroup>,
 }
 
-impl SettingsMenu {
+impl LegacySettingsMenu {
     pub fn new(name: impl Into<SharedString>) -> Self {
         Self {
             name: name.into(),
@@ -321,13 +326,13 @@ impl SettingsMenu {
         }
     }
 
-    pub fn add_group(mut self, group: SettingsGroup) -> Self {
+    pub fn add_group(mut self, group: LegacySettingsGroup) -> Self {
         self.groups.push(group);
         self
     }
 }
 
-impl Render for SettingsMenu {
+impl Render for LegacySettingsMenu {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let is_empty = self.groups.is_empty();
         v_flex()

crates/ui/src/components/settings_container.rs 🔗

@@ -0,0 +1,33 @@
+use gpui::AnyElement;
+use smallvec::SmallVec;
+
+use crate::prelude::*;
+
+#[derive(IntoElement)]
+pub struct SettingsContainer {
+    children: SmallVec<[AnyElement; 2]>,
+}
+
+impl SettingsContainer {
+    pub fn new() -> Self {
+        Self {
+            children: SmallVec::new(),
+        }
+    }
+}
+
+impl ParentElement for SettingsContainer {
+    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
+        self.children.extend(elements)
+    }
+}
+
+impl RenderOnce for SettingsContainer {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        v_flex()
+            .elevation_2(cx)
+            .px_2()
+            .gap_1()
+            .children(self.children)
+    }
+}

crates/ui/src/components/settings_group.rs 🔗

@@ -0,0 +1,36 @@
+use gpui::AnyElement;
+use smallvec::SmallVec;
+
+use crate::{prelude::*, ListHeader};
+
+/// A group of settings.
+#[derive(IntoElement)]
+pub struct SettingsGroup {
+    header: SharedString,
+    children: SmallVec<[AnyElement; 2]>,
+}
+
+impl SettingsGroup {
+    pub fn new(header: impl Into<SharedString>) -> Self {
+        Self {
+            header: header.into(),
+            children: SmallVec::new(),
+        }
+    }
+}
+
+impl ParentElement for SettingsGroup {
+    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
+        self.children.extend(elements)
+    }
+}
+
+impl RenderOnce for SettingsGroup {
+    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
+        v_flex()
+            .p_1()
+            .gap_2()
+            .child(ListHeader::new(self.header))
+            .children(self.children)
+    }
+}

crates/ui/src/components/stories/setting.rs 🔗

@@ -3,12 +3,12 @@ use gpui::View;
 use crate::prelude::*;
 
 use crate::{
-    SecondarySettingType, SettingLayout, SettingType, SettingsGroup, SettingsItem, SettingsMenu,
-    ToggleType,
+    LegacySettingsGroup, LegacySettingsMenu, SecondarySettingType, SettingLayout, SettingType,
+    SettingsItem, ToggleType,
 };
 
 pub struct SettingStory {
-    menus: Vec<(SharedString, View<SettingsMenu>)>,
+    menus: Vec<(SharedString, View<LegacySettingsMenu>)>,
 }
 
 impl SettingStory {
@@ -27,7 +27,7 @@ impl SettingStory {
 
 impl SettingStory {
     pub fn empty_menu(&mut self, cx: &mut ViewContext<Self>) {
-        let menu = cx.new_view(|_cx| SettingsMenu::new("Empty Menu"));
+        let menu = cx.new_view(|_cx| LegacySettingsMenu::new("Empty Menu"));
 
         self.menus.push(("Empty Menu".into(), menu));
     }
@@ -55,18 +55,18 @@ impl SettingStory {
         )
         .layout(SettingLayout::FullLineJustified);
 
-        let group = SettingsGroup::new("Appearance")
+        let group = LegacySettingsGroup::new("Appearance")
             .add_setting(theme_setting)
             .add_setting(appearance_setting)
             .add_setting(high_contrast_setting);
 
-        let menu = cx.new_view(|_cx| SettingsMenu::new("Appearance").add_group(group));
+        let menu = cx.new_view(|_cx| LegacySettingsMenu::new("Appearance").add_group(group));
 
         self.menus.push(("Single Group".into(), menu));
     }
 
     pub fn editor_example(&mut self, cx: &mut ViewContext<Self>) {
-        let font_group = SettingsGroup::new("Font")
+        let font_group = LegacySettingsGroup::new("Font")
             .add_setting(
                 SettingsItem::new(
                     "font-family",
@@ -117,7 +117,7 @@ impl SettingStory {
                 .toggled(true),
             );
 
-        let editor_group = SettingsGroup::new("Editor")
+        let editor_group = LegacySettingsGroup::new("Editor")
             .add_setting(
                 SettingsItem::new(
                     "show-indent-guides",
@@ -137,7 +137,7 @@ impl SettingStory {
                 .toggled(false),
             );
 
-        let gutter_group = SettingsGroup::new("Gutter")
+        let gutter_group = LegacySettingsGroup::new("Gutter")
             .add_setting(
                 SettingsItem::new(
                     "enable-git-hunks",
@@ -158,7 +158,7 @@ impl SettingStory {
                 .layout(SettingLayout::FullLineJustified),
             );
 
-        let scrollbar_group = SettingsGroup::new("Scrollbar")
+        let scrollbar_group = LegacySettingsGroup::new("Scrollbar")
             .add_setting(
                 SettingsItem::new(
                     "scrollbar-visibility",
@@ -198,7 +198,7 @@ impl SettingStory {
             );
 
         let menu = cx.new_view(|_cx| {
-            SettingsMenu::new("Editor")
+            LegacySettingsMenu::new("Editor")
                 .add_group(font_group)
                 .add_group(editor_group)
                 .add_group(gutter_group)