From 0e0f48d8e1707377654cf6b1ee236162d2d16c8d Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 25 Sep 2025 18:08:55 -0700 Subject: [PATCH] Introduce SettingsField type to the settings UI (#38921) Release Notes: - N/A --------- Co-authored-by: Nathan Sobo Co-authored-by: Anthony Eid Co-authored-by: Ben Kunkle --- 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(-) create mode 100644 crates/settings_ui/src/components.rs diff --git a/crates/settings_ui/examples/ui.rs b/crates/settings_ui/examples/ui.rs index ae8847c5635b39a9e7afce11f31d6d87e3cb95f2..992f1e39009be01b773c6e3bbf32098858db43d4 100644 --- a/crates/settings_ui/examples/ui.rs +++ b/crates/settings_ui/examples/ui.rs @@ -49,8 +49,8 @@ fn main() { app.run(move |cx| { ::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 diff --git a/crates/settings_ui/src/components.rs b/crates/settings_ui/src/components.rs new file mode 100644 index 0000000000000000000000000000000000000000..42544d99c27b4c786da1217531b4139ad8db6db4 --- /dev/null +++ b/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, + placeholder: Option<&'static str>, + confirm: Option, &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, &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::({ + 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); + } + }) + }) + } +} diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 71979eb9d946a3b6e9494ac92dcf3046b0ed8816..8f2052acf04557236507be3ef49e4af0dc920e66 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/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 { + 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 AnySettingField for SettingField { + fn as_any(&self) -> &dyn Any { + self + } + + fn type_name(&self) -> &'static str { + type_name::() + } + + fn type_id(&self) -> TypeId { + TypeId::of::() + } +} + +#[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( + &mut self, + renderer: impl Fn( + &SettingField, + Option<&SettingsFieldMetadata>, + &mut Window, + &mut App, + ) -> AnyElement + + 'static, + ) -> &mut Self { + let key = TypeId::of::(); + 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::>() + .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 { vec![ SettingsPage { @@ -28,20 +135,20 @@ fn user_settings_data() -> Vec { 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 { 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 { 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 { SettingsPageItem::SettingItem(SettingItem { title: "Cursor Shape", description: "Cursor shape for the editor", - render: |file, window, cx| { - render_dropdown::( - "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 { vec![SettingsPage { title: "Project", @@ -109,12 +218,16 @@ fn project_settings_data() -> Vec { 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::()]; @@ -158,6 +273,22 @@ pub fn init(cx: &mut App) { .detach(); } +fn init_renderers(cx: &mut App) { + cx.default_global::() + .add_renderer::>(|settings_field, _, _, cx| { + render_toggle_button(settings_field.clone(), cx).into_any_element() + }) + .add_renderer::>(|settings_field, metadata, _, cx| { + render_text_field(settings_field.clone(), metadata, cx) + }) + .add_renderer::>(|settings_field, _, _, cx| { + render_toggle_button(settings_field.clone(), cx) + }) + .add_renderer::>(|settings_field, _, window, cx| { + render_dropdown(settings_field.clone(), window, cx) + }); +} + pub fn open_settings_editor(cx: &mut App) -> anyhow::Result> { 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::().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, + metadata: Option>, } #[allow(unused)] @@ -543,95 +674,59 @@ impl Render for SettingsWindow { } } -fn write_setting_value( - get_value: fn(&mut SettingsContent) -> &mut Option, - value: Option, - cx: &mut App, -) { - cx.update_global(|store: &mut SettingsStore, cx| { - store.update_settings_file(::global(cx), move |settings, _cx| { - *get_value(settings) = value; - }); - }); -} - fn render_text_field( - id: &'static str, - _file: SettingsFile, - window: &mut Window, + field: SettingField>, + metadata: Option<&SettingsFieldMetadata>, cx: &mut App, - get_value: fn(&mut SettingsContent) -> &mut Option, ) -> 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::({ - 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(::global(cx), move |settings, _cx| { + *(field.pick_mut)(settings) = new_text; }); - } + }); }) .into_any_element() } -fn render_toggle_button + From + Copy + Send + 'static>( - id: &'static str, - _: SettingsFile, +fn render_toggle_button + From + Copy>( + field: SettingField>, cx: &mut App, - get_value: fn(&mut SettingsContent) -> &mut Option, ) -> 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 + From + 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(::global(cx), move |settings, _cx| { + *(field.pick_mut)(settings) = Some(state.into()); + }); + }); } }) .into_any_element() } fn render_dropdown( - id: &'static str, - _: SettingsFile, + field: SettingField>, window: &mut Window, cx: &mut App, - get_value: fn(&mut SettingsContent) -> &mut Option, ) -> AnyElement where T: strum::VariantArray + strum::VariantNames + Copy + PartialEq + Send + 'static, @@ -666,20 +761,20 @@ where let labels = || -> &'static [&'static str] { ::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( + ::global(cx), + move |settings, _cx| { + *(field.pick_mut)(settings) = Some(value); + }, + ); + }); }, ); }