Introduce SettingsField type to the settings UI (#38921)

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>
This commit is contained in:
Mikayla Maki
2025-09-26 01:08:55 +00:00
committed by GitHub
co-authored by Nathan Sobo Anthony Eid Ben Kunkle
parent 7980dbdaea
commit 0e0f48d8e1
3 changed files with 323 additions and 143 deletions
+1 -1
View File
@@ -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
+78
View File
@@ -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);
}
})
})
}
}
+244 -142
View File
@@ -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(),
)
.max_w_1_2(),
)
.child(renderer.render(
setting_item.field.as_ref(),
setting_item.metadata.as_deref(),
window,
cx,
)),
)
.into_any_element()
}
}
}
}
impl SettingsPageItem {
fn _header(&self) -> Option<&'static str> {
match self {
SettingsPageItem::SectionHeader(header) => Some(header),
_ => None,
}
}
}
#[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);
},
);
});
},
);
}