settings_ui: Add Basic Implementation of Language Settings (#37803)

Closes #ISSUE

Release Notes:

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

---------

Co-authored-by: Anthony <anthony@zed.dev>
This commit is contained in:
Ben Kunkle
2025-09-08 20:14:36 -04:00
committed by GitHub
co-authored by Anthony
parent 1feffad5e8
commit 8c93112869
5 changed files with 278 additions and 94 deletions
+177 -67
View File
@@ -1,6 +1,7 @@
mod appearance_settings_controls;
use std::any::TypeId;
use std::num::NonZeroU32;
use std::ops::{Not, Range};
use anyhow::Context as _;
@@ -9,8 +10,9 @@ use editor::EditorSettingsControls;
use feature_flags::{FeatureFlag, FeatureFlagViewExt};
use gpui::{App, Entity, EventEmitter, FocusHandle, Focusable, ReadGlobal, ScrollHandle, actions};
use settings::{
NumType, SettingsStore, SettingsUiEntry, SettingsUiItem, SettingsUiItemDynamic,
SettingsUiItemGroup, SettingsUiItemSingle, SettingsValue,
NumType, SettingsStore, SettingsUiEntry, SettingsUiEntryMetaData, SettingsUiItem,
SettingsUiItemDynamicMap, SettingsUiItemGroup, SettingsUiItemSingle, SettingsUiItemUnion,
SettingsValue,
};
use smallvec::SmallVec;
use ui::{NumericStepper, SwitchField, ToggleButtonGroup, ToggleButtonSimple, prelude::*};
@@ -135,9 +137,9 @@ impl Item for SettingsPage {
// - Do we want to show the parent groups when a item is matched?
struct UiEntry {
title: &'static str,
path: Option<&'static str>,
documentation: Option<&'static str>,
title: SharedString,
path: Option<SharedString>,
documentation: Option<SharedString>,
_depth: usize,
// a
// b < a descendant range < a total descendant range
@@ -154,6 +156,11 @@ struct UiEntry {
/// For dynamic items this is a way to select a value from a list of values
/// this is always none for non-dynamic items
select_descendant: Option<fn(&serde_json::Value, &App) -> usize>,
generate_items: Option<(
SettingsUiItem,
fn(&serde_json::Value, &App) -> Vec<SettingsUiEntryMetaData>,
SmallVec<[SharedString; 1]>,
)>,
}
impl UiEntry {
@@ -193,15 +200,16 @@ fn build_tree_item(
) {
let index = tree.len();
tree.push(UiEntry {
title: entry.title,
path: entry.path,
documentation: entry.documentation,
title: entry.title.into(),
path: entry.path.map(SharedString::new_static),
documentation: entry.documentation.map(SharedString::new_static),
_depth: depth,
descendant_range: index + 1..index + 1,
total_descendant_range: index + 1..index + 1,
render: None,
next_sibling: None,
select_descendant: None,
generate_items: None,
});
if let Some(prev_index) = prev_index {
tree[prev_index].next_sibling = Some(index);
@@ -222,7 +230,7 @@ fn build_tree_item(
SettingsUiItem::Single(item) => {
tree[index].render = Some(item);
}
SettingsUiItem::Dynamic(SettingsUiItemDynamic {
SettingsUiItem::Union(SettingsUiItemUnion {
options,
determine_option,
}) => {
@@ -238,6 +246,21 @@ fn build_tree_item(
tree[index].total_descendant_range.end = tree.len();
}
}
SettingsUiItem::DynamicMap(SettingsUiItemDynamicMap {
item: generate_settings_ui_item,
determine_items,
defaults_path,
}) => {
tree[index].generate_items = Some((
generate_settings_ui_item(),
determine_items,
defaults_path
.into_iter()
.copied()
.map(SharedString::new_static)
.collect(),
));
}
SettingsUiItem::None => {
return;
}
@@ -263,7 +286,7 @@ impl SettingsUiTree {
build_tree_item(&mut tree, item, 0, prev_root_entry_index);
}
root_entry_indices.sort_by_key(|i| tree[*i].title);
root_entry_indices.sort_by_key(|i| &tree[*i].title);
let active_entry_index = root_entry_indices[0];
Self {
@@ -276,18 +299,18 @@ impl SettingsUiTree {
// todo(settings_ui): Make sure `Item::None` paths are added to the paths tree,
// so that we can keep none/skip and still test in CI that all settings have
#[cfg(feature = "test-support")]
pub fn all_paths(&self, cx: &App) -> Vec<Vec<&'static str>> {
pub fn all_paths(&self, cx: &App) -> Vec<Vec<SharedString>> {
fn all_paths_rec(
tree: &[UiEntry],
paths: &mut Vec<Vec<&'static str>>,
current_path: &mut Vec<&'static str>,
paths: &mut Vec<Vec<SharedString>>,
current_path: &mut Vec<SharedString>,
idx: usize,
cx: &App,
) {
let child = &tree[idx];
let mut pushed_path = false;
if let Some(path) = child.path.as_ref() {
current_path.push(path);
current_path.push(path.clone());
paths.push(current_path.clone());
pushed_path = true;
}
@@ -340,7 +363,7 @@ fn render_nav(tree: &SettingsUiTree, _window: &mut Window, cx: &mut Context<Sett
settings.settings_tree.active_entry_index = index;
}))
.child(
Label::new(SharedString::new_static(tree.entries[index].title))
Label::new(tree.entries[index].title.clone())
.size(LabelSize::Large)
.when(tree.active_entry_index == index, |this| {
this.color(Color::Selected)
@@ -361,45 +384,102 @@ fn render_content(
let mut path = smallvec::smallvec![];
fn render_recursive(
tree: &SettingsUiTree,
tree: &[UiEntry],
index: usize,
path: &mut SmallVec<[&'static str; 1]>,
path: &mut SmallVec<[SharedString; 1]>,
mut element: Div,
// todo(settings_ui): can this be a ref without cx borrow issues?
fallback_path: &mut Option<SmallVec<[SharedString; 1]>>,
window: &mut Window,
cx: &mut App,
) -> Div {
let Some(child) = tree.entries.get(index) else {
let Some(child) = tree.get(index) else {
return element.child(
Label::new(SharedString::new_static("No settings found")).color(Color::Error),
);
};
element =
element.child(Label::new(SharedString::new_static(child.title)).size(LabelSize::Large));
element = element.child(Label::new(child.title.clone()).size(LabelSize::Large));
// todo(settings_ui): subgroups?
let mut pushed_path = false;
if let Some(child_path) = child.path {
path.push(child_path);
if let Some(child_path) = child.path.as_ref() {
path.push(child_path.clone());
if let Some(fallback_path) = fallback_path.as_mut() {
fallback_path.push(child_path.clone());
}
pushed_path = true;
}
// let fallback_path_copy = fallback_path.cloned();
let settings_value = settings_value_from_settings_and_path(
path.clone(),
child.title,
child.documentation,
fallback_path.as_ref().map(|path| path.as_slice()),
child.title.clone(),
child.documentation.clone(),
// PERF: how to structure this better? There feels like there's a way to avoid the clone
// and every value lookup
SettingsStore::global(cx).raw_user_settings(),
SettingsStore::global(cx).raw_default_settings(),
);
if let Some(select_descendant) = child.select_descendant {
let selected_descendant = child
.nth_descendant_index(&tree.entries, select_descendant(settings_value.read(), cx));
let selected_descendant =
child.nth_descendant_index(tree, select_descendant(settings_value.read(), cx));
if let Some(descendant_index) = selected_descendant {
element = render_recursive(&tree, descendant_index, path, element, window, cx);
element = render_recursive(
tree,
descendant_index,
path,
element,
fallback_path,
window,
cx,
);
}
}
if let Some(child_render) = child.render.as_ref() {
} else if let Some((settings_ui_item, generate_items, defaults_path)) =
child.generate_items.as_ref()
{
let generated_items = generate_items(settings_value.read(), cx);
let mut ui_items = Vec::with_capacity(generated_items.len());
for item in generated_items {
let settings_ui_entry = SettingsUiEntry {
path: None,
title: "",
documentation: None,
item: settings_ui_item.clone(),
};
let prev_index = if ui_items.is_empty() {
None
} else {
Some(ui_items.len() - 1)
};
let item_index = ui_items.len();
build_tree_item(
&mut ui_items,
settings_ui_entry,
child._depth + 1,
prev_index,
);
if item_index < ui_items.len() {
ui_items[item_index].path = None;
ui_items[item_index].title = item.title.clone();
ui_items[item_index].documentation = item.documentation.clone();
// push path instead of setting path on ui item so that the path isn't pushed to default_path as well
// when we recurse
path.push(item.path.clone());
element = render_recursive(
&ui_items,
item_index,
path,
element,
&mut Some(defaults_path.clone()),
window,
cx,
);
path.pop();
}
}
} else if let Some(child_render) = child.render.as_ref() {
element = element.child(div().child(render_item_single(
settings_value,
child_render,
@@ -409,8 +489,16 @@ fn render_content(
} else if let Some(child_index) = child.first_descendant_index() {
let mut index = Some(child_index);
while let Some(sub_child_index) = index {
element = render_recursive(tree, sub_child_index, path, element, window, cx);
index = tree.entries[sub_child_index].next_sibling;
element = render_recursive(
tree,
sub_child_index,
path,
element,
fallback_path,
window,
cx,
);
index = tree[sub_child_index].next_sibling;
}
} else {
element =
@@ -419,15 +507,19 @@ fn render_content(
if pushed_path {
path.pop();
if let Some(fallback_path) = fallback_path.as_mut() {
fallback_path.pop();
}
}
return element;
}
return render_recursive(
tree,
&tree.entries,
tree.active_entry_index,
&mut path,
content,
&mut None,
window,
cx,
);
@@ -484,15 +576,15 @@ fn render_old_appearance_settings(cx: &mut App) -> impl IntoElement {
)
}
fn element_id_from_path(path: &[&'static str]) -> ElementId {
fn element_id_from_path(path: &[SharedString]) -> ElementId {
if path.len() == 0 {
panic!("Path length must not be zero");
} else if path.len() == 1 {
ElementId::Name(SharedString::new_static(path[0]))
ElementId::Name(path[0].clone())
} else {
ElementId::from((
ElementId::from(SharedString::new_static(path[path.len() - 2])),
SharedString::new_static(path[path.len() - 1]),
ElementId::from(path[path.len() - 2].clone()),
path[path.len() - 1].clone(),
))
}
}
@@ -525,13 +617,13 @@ fn render_item_single(
pub fn read_settings_value_from_path<'a>(
settings_contents: &'a serde_json::Value,
path: &[&str],
path: &[impl AsRef<str>],
) -> Option<&'a serde_json::Value> {
// todo(settings_ui) make non recursive, and move to `settings` alongside SettingsValue, and add method to SettingsValue to get nested
let Some((key, remaining)) = path.split_first() else {
return Some(settings_contents);
};
let Some(value) = settings_contents.get(key) else {
let Some(value) = settings_contents.get(key.as_ref()) else {
return None;
};
@@ -541,13 +633,17 @@ pub fn read_settings_value_from_path<'a>(
fn downcast_any_item<T: serde::de::DeserializeOwned>(
settings_value: SettingsValue<serde_json::Value>,
) -> SettingsValue<T> {
let value = settings_value
.value
.map(|value| serde_json::from_value::<T>(value).expect("value is not a T"));
let value = settings_value.value.map(|value| {
serde_json::from_value::<T>(value.clone())
.with_context(|| format!("path: {:?}", settings_value.path.join(".")))
.with_context(|| format!("value is not a {}: {}", std::any::type_name::<T>(), value))
.unwrap()
});
// todo(settings_ui) Create test that constructs UI tree, and asserts that all elements have default values
let default_value = serde_json::from_value::<T>(settings_value.default_value)
.with_context(|| format!("path: {:?}", settings_value.path.join(".")))
.expect("default value is not an Option<T>");
.with_context(|| format!("value is not a {}", std::any::type_name::<T>()))
.unwrap();
let deserialized_setting_value = SettingsValue {
title: settings_value.title,
path: settings_value.path,
@@ -577,8 +673,8 @@ fn render_any_numeric_stepper(
match num_type {
NumType::U64 => render_numeric_stepper::<u64>(
downcast_any_item(settings_value),
u64::saturating_sub,
u64::saturating_add,
|n| u64::saturating_sub(n, 1),
|n| u64::saturating_add(n, 1),
|n| {
serde_json::Number::try_from(n)
.context("Failed to convert u64 to serde_json::Number")
@@ -588,8 +684,8 @@ fn render_any_numeric_stepper(
),
NumType::U32 => render_numeric_stepper::<u32>(
downcast_any_item(settings_value),
u32::saturating_sub,
u32::saturating_add,
|n| u32::saturating_sub(n, 1),
|n| u32::saturating_add(n, 1),
|n| {
serde_json::Number::try_from(n)
.context("Failed to convert u32 to serde_json::Number")
@@ -599,8 +695,8 @@ fn render_any_numeric_stepper(
),
NumType::F32 => render_numeric_stepper::<f32>(
downcast_any_item(settings_value),
|a, b| a - b,
|a, b| a + b,
|a| a - 1.0,
|a| a + 1.0,
|n| {
serde_json::Number::from_f64(n as f64)
.context("Failed to convert f32 to serde_json::Number")
@@ -610,8 +706,8 @@ fn render_any_numeric_stepper(
),
NumType::USIZE => render_numeric_stepper::<usize>(
downcast_any_item(settings_value),
usize::saturating_sub,
usize::saturating_add,
|n| usize::saturating_sub(n, 1),
|n| usize::saturating_add(n, 1),
|n| {
serde_json::Number::try_from(n)
.context("Failed to convert usize to serde_json::Number")
@@ -619,15 +715,24 @@ fn render_any_numeric_stepper(
window,
cx,
),
NumType::U32NONZERO => render_numeric_stepper::<NonZeroU32>(
downcast_any_item(settings_value),
|a| NonZeroU32::new(u32::saturating_sub(a.get(), 1)).unwrap_or(NonZeroU32::MIN),
|a| NonZeroU32::new(u32::saturating_add(a.get(), 1)).unwrap_or(NonZeroU32::MAX),
|n| {
serde_json::Number::try_from(n.get())
.context("Failed to convert usize to serde_json::Number")
},
window,
cx,
),
}
}
fn render_numeric_stepper<
T: serde::de::DeserializeOwned + std::fmt::Display + Copy + From<u8> + 'static,
>(
fn render_numeric_stepper<T: serde::de::DeserializeOwned + std::fmt::Display + Copy + 'static>(
value: SettingsValue<T>,
saturating_sub: fn(T, T) -> T,
saturating_add: fn(T, T) -> T,
saturating_sub_1: fn(T) -> T,
saturating_add_1: fn(T) -> T,
to_serde_number: fn(T) -> anyhow::Result<serde_json::Number>,
_window: &mut Window,
_cx: &mut App,
@@ -640,9 +745,9 @@ fn render_numeric_stepper<
id,
num.to_string(),
{
let path = value.path.clone();
let path = value.path;
move |_, _, cx| {
let Some(number) = to_serde_number(saturating_sub(num, 1.into())).ok() else {
let Some(number) = to_serde_number(saturating_sub_1(num)).ok() else {
return;
};
let new_value = serde_json::Value::Number(number);
@@ -650,7 +755,7 @@ fn render_numeric_stepper<
}
},
move |_, _, cx| {
let Some(number) = to_serde_number(saturating_add(num, 1.into())).ok() else {
let Some(number) = to_serde_number(saturating_add_1(num)).ok() else {
return;
};
@@ -672,8 +777,8 @@ fn render_switch_field(
let path = value.path.clone();
SwitchField::new(
id,
SharedString::new_static(value.title),
value.documentation.map(SharedString::new_static),
value.title.clone(),
value.documentation.clone(),
match value.read() {
true => ToggleState::Selected,
false => ToggleState::Unselected,
@@ -703,7 +808,6 @@ fn render_toggle_button_group(
let value = downcast_any_item::<String>(value);
fn make_toggle_group<const LEN: usize>(
group_name: &'static str,
value: SettingsValue<String>,
variants: &'static [&'static str],
labels: &'static [&'static str],
@@ -727,7 +831,7 @@ fn render_toggle_button_group(
let mut idx = 0;
ToggleButtonGroup::single_row(
group_name,
value.title.clone(),
variants_array.map(|(variant, label)| {
let path = value.path.clone();
idx += 1;
@@ -748,7 +852,7 @@ fn render_toggle_button_group(
macro_rules! templ_toggl_with_const_param {
($len:expr) => {
if variants.len() == $len {
return make_toggle_group::<$len>(value.title, value, variants, labels);
return make_toggle_group::<$len>(value, variants, labels);
}
};
}
@@ -762,13 +866,19 @@ fn render_toggle_button_group(
}
fn settings_value_from_settings_and_path(
path: SmallVec<[&'static str; 1]>,
title: &'static str,
documentation: Option<&'static str>,
path: SmallVec<[SharedString; 1]>,
fallback_path: Option<&[SharedString]>,
title: SharedString,
documentation: Option<SharedString>,
user_settings: &serde_json::Value,
default_settings: &serde_json::Value,
) -> SettingsValue<serde_json::Value> {
let default_value = read_settings_value_from_path(default_settings, &path)
.or_else(|| {
fallback_path.and_then(|fallback_path| {
read_settings_value_from_path(default_settings, fallback_path)
})
})
.with_context(|| format!("No default value for item at path {:?}", path.join(".")))
.expect("Default value set for item")
.clone();
@@ -778,7 +888,7 @@ fn settings_value_from_settings_and_path(
default_value,
value,
documentation,
path: path.clone(),
path,
// todo(settings_ui) is title required inside SettingsValue?
title,
};