Introduce SettingsField type to the settings UI (#38921)

Mikayla Maki , Nathan Sobo , Anthony Eid , and Ben Kunkle created

Release Notes:

- N/A

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
Co-authored-by: Ben Kunkle <ben@zed.dev>

Change summary

crates/settings_ui/examples/ui.rs     |   2 
crates/settings_ui/src/components.rs  |  78 +++++
crates/settings_ui/src/settings_ui.rs | 386 ++++++++++++++++++----------
3 files changed, 323 insertions(+), 143 deletions(-)

Detailed changes

crates/settings_ui/examples/ui.rs 🔗

@@ -49,8 +49,8 @@ fn main() {
     app.run(move |cx| {
         <dyn fs::Fs>::set_global(fs.clone(), cx);
         settings::init(cx);
+        settings_ui::init(cx);
         theme::init(theme::LoadThemes::JustBase, cx);
-
         client::init_settings(cx);
         workspace::init_settings(cx);
         // production client because fake client requires gpui/test-support

crates/settings_ui/src/components.rs 🔗

@@ -0,0 +1,78 @@
+use editor::Editor;
+use gpui::div;
+use ui::{
+    ActiveTheme as _, App, FluentBuilder as _, InteractiveElement as _, IntoElement,
+    ParentElement as _, RenderOnce, Styled as _, Window,
+};
+
+#[derive(IntoElement)]
+pub struct SettingsEditor {
+    initial_text: Option<String>,
+    placeholder: Option<&'static str>,
+    confirm: Option<Box<dyn Fn(Option<String>, &mut App)>>,
+}
+
+impl SettingsEditor {
+    pub fn new() -> Self {
+        Self {
+            initial_text: None,
+            placeholder: None,
+            confirm: None,
+        }
+    }
+
+    pub fn with_initial_text(mut self, initial_text: String) -> Self {
+        self.initial_text = Some(initial_text);
+        self
+    }
+
+    pub fn with_placeholder(mut self, placeholder: &'static str) -> Self {
+        self.placeholder = Some(placeholder);
+        self
+    }
+
+    pub fn on_confirm(mut self, confirm: impl Fn(Option<String>, &mut App) + 'static) -> Self {
+        self.confirm = Some(Box::new(confirm));
+        self
+    }
+}
+
+impl RenderOnce for SettingsEditor {
+    fn render(self, window: &mut Window, cx: &mut App) -> impl ui::IntoElement {
+        let editor = window.use_state(cx, {
+            move |window, cx| {
+                let mut editor = Editor::single_line(window, cx);
+                if let Some(text) = self.initial_text {
+                    editor.set_text(text, window, cx);
+                }
+
+                if let Some(placeholder) = self.placeholder {
+                    editor.set_placeholder_text(placeholder, window, cx);
+                }
+                editor
+            }
+        });
+
+        let weak_editor = editor.downgrade();
+        let theme_colors = cx.theme().colors();
+
+        div()
+            .child(editor)
+            .bg(theme_colors.editor_background)
+            .border_1()
+            .rounded_lg()
+            .border_color(theme_colors.border)
+            .when_some(self.confirm, |this, confirm| {
+                this.on_action::<menu::Confirm>({
+                    move |_, _, cx| {
+                        let Some(editor) = weak_editor.upgrade() else {
+                            return;
+                        };
+                        let new_value = editor.read_with(cx, |editor, cx| editor.text(cx));
+                        let new_value = (!new_value.is_empty()).then_some(new_value);
+                        confirm(new_value, cx);
+                    }
+                })
+            })
+    }
+}

crates/settings_ui/src/settings_ui.rs 🔗

@@ -1,15 +1,22 @@
 //! # settings_ui
-use std::{ops::Range, sync::Arc};
-
+mod components;
 use editor::Editor;
 use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
 use gpui::{
-    App, AppContext as _, Context, Div, Entity, IntoElement, ReadGlobal as _, Render,
+    App, AppContext as _, Context, Div, Entity, Global, IntoElement, ReadGlobal as _, Render,
     UniformListScrollHandle, Window, WindowHandle, WindowOptions, actions, div, px, size,
     uniform_list,
 };
 use project::WorktreeId;
-use settings::{SettingsContent, SettingsStore};
+use settings::{CursorShape, SaturatingBool, SettingsContent, SettingsStore};
+use std::{
+    any::{Any, TypeId, type_name},
+    cell::RefCell,
+    collections::HashMap,
+    ops::Range,
+    rc::Rc,
+    sync::Arc,
+};
 use ui::{
     ActiveTheme as _, AnyElement, BorrowAppContext as _, Button, Clickable as _, Color, Divider,
     DropdownMenu, FluentBuilder as _, Icon, IconName, InteractiveElement as _, Label,
@@ -18,6 +25,106 @@ use ui::{
 };
 use util::{paths::PathStyle, rel_path::RelPath};
 
+use crate::components::SettingsEditor;
+
+#[derive(Clone)]
+struct SettingField<T: 'static> {
+    pick: fn(&SettingsContent) -> &T,
+    pick_mut: fn(&mut SettingsContent) -> &mut T,
+}
+
+trait AnySettingField {
+    fn as_any(&self) -> &dyn Any;
+    fn type_name(&self) -> &'static str;
+    fn type_id(&self) -> TypeId;
+}
+
+impl<T> AnySettingField for SettingField<T> {
+    fn as_any(&self) -> &dyn Any {
+        self
+    }
+
+    fn type_name(&self) -> &'static str {
+        type_name::<T>()
+    }
+
+    fn type_id(&self) -> TypeId {
+        TypeId::of::<T>()
+    }
+}
+
+#[derive(Default, Clone)]
+struct SettingFieldRenderer {
+    renderers: Rc<
+        RefCell<
+            HashMap<
+                TypeId,
+                Box<
+                    dyn Fn(
+                        &dyn AnySettingField,
+                        Option<&SettingsFieldMetadata>,
+                        &mut Window,
+                        &mut App,
+                    ) -> AnyElement,
+                >,
+            >,
+        >,
+    >,
+}
+
+impl Global for SettingFieldRenderer {}
+
+impl SettingFieldRenderer {
+    fn add_renderer<T: 'static>(
+        &mut self,
+        renderer: impl Fn(
+            &SettingField<T>,
+            Option<&SettingsFieldMetadata>,
+            &mut Window,
+            &mut App,
+        ) -> AnyElement
+        + 'static,
+    ) -> &mut Self {
+        let key = TypeId::of::<T>();
+        let renderer = Box::new(
+            move |any_setting_field: &dyn AnySettingField,
+                  metadata: Option<&SettingsFieldMetadata>,
+                  window: &mut Window,
+                  cx: &mut App| {
+                let field = any_setting_field
+                    .as_any()
+                    .downcast_ref::<SettingField<T>>()
+                    .unwrap();
+                renderer(field, metadata, window, cx)
+            },
+        );
+        self.renderers.borrow_mut().insert(key, renderer);
+        self
+    }
+
+    fn render(
+        &self,
+        any_setting_field: &dyn AnySettingField,
+        metadata: Option<&SettingsFieldMetadata>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> AnyElement {
+        let key = any_setting_field.type_id();
+        if let Some(renderer) = self.renderers.borrow().get(&key) {
+            renderer(any_setting_field, metadata, window, cx)
+        } else {
+            panic!(
+                "No renderer found for type: {}",
+                any_setting_field.type_name()
+            )
+        }
+    }
+}
+
+struct SettingsFieldMetadata {
+    placeholder: Option<&'static str>,
+}
+
 fn user_settings_data() -> Vec<SettingsPage> {
     vec![
         SettingsPage {
@@ -28,20 +135,20 @@ fn user_settings_data() -> Vec<SettingsPage> {
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Confirm Quit",
                     description: "Whether to confirm before quitting Zed",
-                    render: |file, _, cx| {
-                        render_toggle_button("confirm_quit", file, cx, |settings_content| {
-                            &mut settings_content.workspace.confirm_quit
-                        })
-                    },
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.workspace.confirm_quit,
+                        pick_mut: |settings_content| &mut settings_content.workspace.confirm_quit,
+                    }),
+                    metadata: None,
                 }),
                 SettingsPageItem::SettingItem(SettingItem {
                     title: "Auto Update",
                     description: "Automatically update Zed (may be ignored on Linux if installed through a package manager)",
-                    render: |file, _, cx| {
-                        render_toggle_button("Auto Update", file, cx, |settings_content| {
-                            &mut settings_content.auto_update
-                        })
-                    },
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.auto_update,
+                        pick_mut: |settings_content| &mut settings_content.auto_update,
+                    }),
+                    metadata: None,
                 }),
                 SettingsPageItem::SectionHeader("Privacy"),
             ],
@@ -54,11 +161,15 @@ 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: |file, window, cx| {
-                        render_text_field("project_name", file, window, cx, |settings_content| {
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.project.worktree.project_name,
+                        pick_mut: |settings_content| {
                             &mut settings_content.project.worktree.project_name
-                        })
-                    },
+                        },
+                    }),
+                    metadata: Some(Box::new(SettingsFieldMetadata {
+                        placeholder: Some("A new name"),
+                    })),
                 }),
             ],
         },
@@ -70,11 +181,11 @@ fn user_settings_data() -> Vec<SettingsPage> {
                 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
-                        })
-                    },
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.disable_ai,
+                        pick_mut: |settings_content| &mut settings_content.disable_ai,
+                    }),
+                    metadata: None,
                 }),
             ],
         },
@@ -86,21 +197,19 @@ fn user_settings_data() -> Vec<SettingsPage> {
                 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.editor.cursor_shape,
-                        )
-                    },
+                    field: Box::new(SettingField {
+                        pick: |settings_content| &settings_content.editor.cursor_shape,
+                        pick_mut: |settings_content| &mut settings_content.editor.cursor_shape,
+                    }),
+                    metadata: None,
                 }),
             ],
         },
     ]
 }
 
+// Derive Macro, on the new ProjectSettings struct
+
 fn project_settings_data() -> Vec<SettingsPage> {
     vec![SettingsPage {
         title: "Project",
@@ -109,12 +218,16 @@ fn project_settings_data() -> Vec<SettingsPage> {
             SettingsPageItem::SectionHeader("Worktree Settings Content"),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Project Name",
-                description: " The displayed name of this project. If not set, the root directory name",
-                render: |file, window, cx| {
-                    render_text_field("project_name", file, window, cx, |settings_content| {
+                description: "The displayed name of this project. If not set, the root directory name",
+                field: Box::new(SettingField {
+                    pick: |settings_content| &settings_content.project.worktree.project_name,
+                    pick_mut: |settings_content| {
                         &mut settings_content.project.worktree.project_name
-                    })
-                },
+                    },
+                }),
+                metadata: Some(Box::new(SettingsFieldMetadata {
+                    placeholder: Some("A new name"),
+                })),
             }),
         ],
     }]
@@ -135,6 +248,8 @@ actions!(
 );
 
 pub fn init(cx: &mut App) {
+    init_renderers(cx);
+
     cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
         workspace.register_action_renderer(|div, _, _, cx| {
             let settings_ui_actions = [std::any::TypeId::of::<OpenSettingsEditor>()];
@@ -158,6 +273,22 @@ pub fn init(cx: &mut App) {
     .detach();
 }
 
+fn init_renderers(cx: &mut App) {
+    cx.default_global::<SettingFieldRenderer>()
+        .add_renderer::<Option<bool>>(|settings_field, _, _, cx| {
+            render_toggle_button(settings_field.clone(), cx).into_any_element()
+        })
+        .add_renderer::<Option<String>>(|settings_field, metadata, _, cx| {
+            render_text_field(settings_field.clone(), metadata, cx)
+        })
+        .add_renderer::<Option<SaturatingBool>>(|settings_field, _, _, cx| {
+            render_toggle_button(settings_field.clone(), cx)
+        })
+        .add_renderer::<Option<CursorShape>>(|settings_field, _, window, cx| {
+            render_dropdown(settings_field.clone(), window, cx)
+        });
+}
+
 pub fn open_settings_editor(cx: &mut App) -> anyhow::Result<WindowHandle<SettingsWindow>> {
     cx.open_window(
         WindowOptions {
@@ -188,7 +319,6 @@ struct NavBarEntry {
     is_root: bool,
 }
 
-#[derive(Clone)]
 struct SettingsPage {
     title: &'static str,
     expanded: bool,
@@ -204,58 +334,59 @@ impl SettingsPage {
     }
 }
 
-#[derive(Clone)]
 enum SettingsPageItem {
     SectionHeader(&'static str),
     SettingItem(SettingItem),
 }
 
 impl SettingsPageItem {
-    fn render(&self, file: SettingsFile, window: &mut Window, cx: &mut App) -> AnyElement {
+    fn render(&self, _file: SettingsFile, window: &mut Window, cx: &mut App) -> AnyElement {
         match self {
             SettingsPageItem::SectionHeader(header) => div()
                 .w_full()
                 .child(Label::new(SharedString::new_static(header)).size(LabelSize::Large))
                 .child(Divider::horizontal().color(ui::DividerColor::BorderVariant))
                 .into_any_element(),
-            SettingsPageItem::SettingItem(setting_item) => div()
-                .child(
-                    Label::new(SharedString::new_static(setting_item.title))
-                        .size(LabelSize::Default),
-                )
-                .child(
-                    h_flex()
-                        .justify_between()
-                        .child(
-                            div()
-                                .child(
-                                    Label::new(SharedString::new_static(setting_item.description))
+            SettingsPageItem::SettingItem(setting_item) => {
+                let renderer = cx.default_global::<SettingFieldRenderer>().clone();
+                div()
+                    .id(setting_item.title)
+                    .child(
+                        Label::new(SharedString::new_static(setting_item.title))
+                            .size(LabelSize::Default),
+                    )
+                    .child(
+                        h_flex()
+                            .justify_between()
+                            .child(
+                                div()
+                                    .child(
+                                        Label::new(SharedString::new_static(
+                                            setting_item.description,
+                                        ))
                                         .size(LabelSize::Small)
                                         .color(Color::Muted),
-                                )
-                                .max_w_1_2(),
-                        )
-                        .child((setting_item.render)(file, window, cx)),
-                )
-                .into_any_element(),
-        }
-    }
-}
-
-impl SettingsPageItem {
-    fn _header(&self) -> Option<&'static str> {
-        match self {
-            SettingsPageItem::SectionHeader(header) => Some(header),
-            _ => None,
+                                    )
+                                    .max_w_1_2(),
+                            )
+                            .child(renderer.render(
+                                setting_item.field.as_ref(),
+                                setting_item.metadata.as_deref(),
+                                window,
+                                cx,
+                            )),
+                    )
+                    .into_any_element()
+            }
         }
     }
 }
 
-#[derive(Clone)]
 struct SettingItem {
     title: &'static str,
     description: &'static str,
-    render: fn(file: SettingsFile, &mut Window, &mut App) -> AnyElement,
+    field: Box<dyn AnySettingField>,
+    metadata: Option<Box<SettingsFieldMetadata>>,
 }
 
 #[allow(unused)]
@@ -543,95 +674,59 @@ impl Render for SettingsWindow {
     }
 }
 
-fn write_setting_value<T: Send + 'static>(
-    get_value: fn(&mut SettingsContent) -> &mut Option<T>,
-    value: Option<T>,
-    cx: &mut App,
-) {
-    cx.update_global(|store: &mut SettingsStore, cx| {
-        store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
-            *get_value(settings) = value;
-        });
-    });
-}
-
 fn render_text_field(
-    id: &'static str,
-    _file: SettingsFile,
-    window: &mut Window,
+    field: SettingField<Option<String>>,
+    metadata: Option<&SettingsFieldMetadata>,
     cx: &mut App,
-    get_value: fn(&mut SettingsContent) -> &mut Option<String>,
 ) -> AnyElement {
-    // TODO: Updating file does not cause the editor text to reload, suspicious it may be a missing global update/notify in SettingsStore
-
     // TODO: in settings window state
     let store = SettingsStore::global(cx);
 
     // TODO: This clone needs to go!!
-    let mut defaults = store.raw_default_settings().clone();
-    let mut user_settings = store
+    let defaults = store.raw_default_settings().clone();
+    let user_settings = store
         .raw_user_settings()
         .cloned()
         .unwrap_or_default()
         .content;
 
-    // TODO: unwrap_or_default here because project name is null
-    let initial_text = get_value(user_settings.as_mut())
+    let initial_text = (field.pick)(&user_settings)
         .clone()
-        .unwrap_or_else(|| get_value(&mut defaults).clone().unwrap_or_default());
+        .or_else(|| (field.pick)(&defaults).clone());
 
-    let editor = window.use_keyed_state((id.into(), initial_text.clone()), cx, {
-        move |window, cx| {
-            let mut editor = Editor::single_line(window, cx);
-            editor.set_text(initial_text, window, cx);
-            editor
-        }
-    });
-
-    let weak_editor = editor.downgrade();
-    let theme_colors = cx.theme().colors();
-
-    div()
-        .child(editor)
-        .bg(theme_colors.editor_background)
-        .border_1()
-        .rounded_lg()
-        .border_color(theme_colors.border)
-        .on_action::<menu::Confirm>({
-            move |_, _, cx| {
-                let Some(editor) = weak_editor.upgrade() else {
-                    return;
-                };
-                let new_value = editor.read_with(cx, |editor, cx| editor.text(cx));
-                let new_value = (!new_value.is_empty()).then_some(new_value);
-                write_setting_value(get_value, new_value, cx);
-                editor.update(cx, |_, cx| {
-                    cx.notify();
+    SettingsEditor::new()
+        .when_some(initial_text, |editor, text| editor.with_initial_text(text))
+        .when_some(
+            metadata.and_then(|metadata| metadata.placeholder),
+            |editor, placeholder| editor.with_placeholder(placeholder),
+        )
+        .on_confirm(move |new_text, cx: &mut App| {
+            cx.update_global(move |store: &mut SettingsStore, cx| {
+                store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
+                    *(field.pick_mut)(settings) = new_text;
                 });
-            }
+            });
         })
         .into_any_element()
 }
 
-fn render_toggle_button<B: Into<bool> + From<bool> + Copy + Send + 'static>(
-    id: &'static str,
-    _: SettingsFile,
+fn render_toggle_button<B: Into<bool> + From<bool> + Copy>(
+    field: SettingField<Option<B>>,
     cx: &mut App,
-    get_value: fn(&mut SettingsContent) -> &mut Option<B>,
 ) -> AnyElement {
     // TODO: in settings window state
     let store = SettingsStore::global(cx);
 
     // TODO: This clone needs to go!!
-    let mut defaults = store.raw_default_settings().clone();
-    let mut user_settings = store
+    let defaults = store.raw_default_settings().clone();
+    let user_settings = store
         .raw_user_settings()
         .cloned()
         .unwrap_or_default()
         .content;
 
-    let toggle_state = if get_value(&mut user_settings)
-        .unwrap_or_else(|| get_value(&mut defaults).unwrap())
+    let toggle_state = if (field.pick)(&user_settings)
+        .unwrap_or_else(|| (field.pick)(&defaults).unwrap())
         .into()
     {
         ui::ToggleState::Selected
@@ -639,25 +734,25 @@ fn render_toggle_button<B: Into<bool> + From<bool> + Copy + Send + 'static>(
         ui::ToggleState::Unselected
     };
 
-    Switch::new(id, toggle_state)
+    Switch::new("toggle_button", toggle_state)
         .on_click({
             move |state, _window, cx| {
-                write_setting_value(
-                    get_value,
-                    Some((*state == ui::ToggleState::Selected).into()),
-                    cx,
-                );
+                let state = *state == ui::ToggleState::Selected;
+                let field = field.clone();
+                cx.update_global(move |store: &mut SettingsStore, cx| {
+                    store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
+                        *(field.pick_mut)(settings) = Some(state.into());
+                    });
+                });
             }
         })
         .into_any_element()
 }
 
 fn render_dropdown<T>(
-    id: &'static str,
-    _: SettingsFile,
+    field: SettingField<Option<T>>,
     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,
@@ -666,20 +761,20 @@ where
     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
+    let defaults = store.raw_default_settings().clone();
+    let 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());
+        (field.pick)(&user_settings).unwrap_or_else(|| (field.pick)(&defaults).unwrap());
     let current_value_label =
         labels()[variants().iter().position(|v| *v == current_value).unwrap()];
 
     DropdownMenu::new(
-        id,
+        "dropdown",
         current_value_label,
         ui::ContextMenu::build(window, cx, move |mut menu, _, _| {
             for (value, label) in variants()
@@ -696,7 +791,14 @@ where
                         if value == current_value {
                             return;
                         }
-                        write_setting_value(get_value, Some(value), cx);
+                        cx.update_global(move |store: &mut SettingsStore, cx| {
+                            store.update_settings_file(
+                                <dyn fs::Fs>::global(cx),
+                                move |settings, _cx| {
+                                    *(field.pick_mut)(settings) = Some(value);
+                                },
+                            );
+                        });
                     },
                 );
             }