From 2ce0641fe073f56cdbd27c6496032a985b9036ec Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Wed, 10 Sep 2025 17:02:08 -0500 Subject: [PATCH] settings_ui: Handle enums with fields (#37945) Closes #ISSUE Adds handling for Enums with fields (i.e. not `enum Foo { Yes, No }`) in Settings UI. Accomplished by creating default values for each element with fields (in the derive macro), and rendering a toggle button group with a button for each variant where switching the active variant sets the value in the settings JSON to the default for the new active variant. Release Notes: - N/A *or* Added/Fixed/Improved ... --------- Co-authored-by: Conrad --- crates/feature_flags/src/feature_flags.rs | 7 +- crates/language/src/language_settings.rs | 22 +- crates/settings/src/settings_ui_core.rs | 12 +- crates/settings_ui/src/settings_ui.rs | 450 ++++++++++-------- .../src/settings_ui_macros.rs | 251 +++++++--- docs/src/visual-customization.md | 4 +- 6 files changed, 459 insertions(+), 287 deletions(-) diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 8a50b7ec9bcc5149360ad7499e5e97d5731dfaa7..4afeb32235114ea6d2e29042e9d4e465043c19da 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -23,7 +23,7 @@ impl FeatureFlags { return true; } - if self.staff && T::enabled_for_staff() { + if (cfg!(debug_assertions) || self.staff) && !*ZED_DISABLE_STAFF && T::enabled_for_staff() { return true; } @@ -210,7 +210,10 @@ impl FeatureFlagAppExt for App { fn has_flag(&self) -> bool { self.try_global::() .map(|flags| flags.has_flag::()) - .unwrap_or(T::enabled_for_all()) + .unwrap_or_else(|| { + (cfg!(debug_assertions) && T::enabled_for_staff() && !*ZED_DISABLE_STAFF) + || T::enabled_for_all() + }) } fn is_staff(&self) -> bool { diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index cb193960aef4c5dcb3cd0f015e23da895651b4f8..af9e6edbfa4ed2ef44d7a5789069a83b7db829c7 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -981,11 +981,17 @@ impl<'de> Deserialize<'de> for SelectedFormatter { } /// Controls which formatters should be used when formatting code. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi)] #[serde(untagged)] pub enum FormatterList { Single(Formatter), - Vec(Vec), + Vec(#[settings_ui(skip)] Vec), +} + +impl Default for FormatterList { + fn default() -> Self { + Self::Single(Formatter::default()) + } } impl AsRef<[Formatter]> for FormatterList { @@ -998,22 +1004,28 @@ impl AsRef<[Formatter]> for FormatterList { } /// Controls which formatter should be used when formatting code. If there are multiple formatters, they are executed in the order of declaration. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi)] #[serde(rename_all = "snake_case")] pub enum Formatter { /// Format code using the current language server. - LanguageServer { name: Option }, + LanguageServer { + #[settings_ui(skip)] + name: Option, + }, /// Format code using Zed's Prettier integration. + #[default] Prettier, /// Format code using an external command. External { /// The external program to run. + #[settings_ui(skip)] command: Arc, /// The arguments to pass to the program. + #[settings_ui(skip)] arguments: Option>, }, /// Files should be formatted using code actions executed by language servers. - CodeActions(HashMap), + CodeActions(#[settings_ui(skip)] HashMap), } /// The settings for indent guides. diff --git a/crates/settings/src/settings_ui_core.rs b/crates/settings/src/settings_ui_core.rs index c3366641a8991796082ed1e89d0bafa729dfc7ce..d4f49785a6442134aba8a639183d082d52ab3baf 100644 --- a/crates/settings/src/settings_ui_core.rs +++ b/crates/settings/src/settings_ui_core.rs @@ -108,10 +108,19 @@ impl SettingsValue { #[derive(Clone)] pub struct SettingsUiItemUnion { - pub options: Vec, + /// Must be the same length as `labels` and `options` + pub defaults: Box<[serde_json::Value]>, + /// Must be the same length as defaults` and `options` + pub labels: &'static [&'static str], + /// Must be the same length as `defaults` and `labels` + pub options: Box<[Option]>, pub determine_option: fn(&serde_json::Value, &App) -> usize, } +// todo(settings_ui): use in ToggleGroup and Dropdown +#[derive(Clone)] +pub struct SettingsEnumVariants {} + pub struct SettingsUiEntryMetaData { pub title: SharedString, pub path: SharedString, @@ -136,6 +145,7 @@ pub enum SettingsUiItem { Single(SettingsUiItemSingle), Union(SettingsUiItemUnion), DynamicMap(SettingsUiItemDynamicMap), + // Array(SettingsUiItemArray), // code-actions: array of objects, array of string None, } diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 4c9419491c34df05bb818ce718dfab16c31730a1..345a99752706272ce954b388c710a1d2a83ed02c 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -1,13 +1,14 @@ mod appearance_settings_controls; -use std::any::TypeId; -use std::num::NonZeroU32; -use std::ops::{Not, Range}; +use std::{ + num::NonZeroU32, + ops::{Not, Range}, + rc::Rc, +}; use anyhow::Context as _; -use command_palette_hooks::CommandPaletteFilter; use editor::{Editor, EditorSettingsControls}; -use feature_flags::{FeatureFlag, FeatureFlagViewExt}; +use feature_flags::{FeatureFlag, FeatureFlagAppExt}; use gpui::{App, Entity, EventEmitter, FocusHandle, Focusable, ReadGlobal, ScrollHandle, actions}; use settings::{ NumType, SettingsStore, SettingsUiEntry, SettingsUiEntryMetaData, SettingsUiItem, @@ -19,7 +20,6 @@ use ui::{NumericStepper, SwitchField, ToggleButtonGroup, ToggleButtonSimple, pre use workspace::{ Workspace, item::{Item, ItemEvent}, - with_active_or_new_workspace, }; use crate::appearance_settings_controls::AppearanceSettingsControls; @@ -38,50 +38,45 @@ actions!( ] ); +pub fn open_settings_editor( + workspace: &mut Workspace, + _: &OpenSettingsEditor, + window: &mut Window, + cx: &mut Context, +) { + // todo(settings_ui) open in a local workspace if this is remote. + let existing = workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()); + + if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + } else { + let settings_page = SettingsPage::new(workspace, cx); + workspace.add_item_to_active_pane(Box::new(settings_page), None, true, window, cx) + } +} + pub fn init(cx: &mut App) { - cx.on_action(|_: &OpenSettingsEditor, cx| { - with_active_or_new_workspace(cx, move |workspace, window, cx| { - let existing = workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()); - - if let Some(existing) = existing { - workspace.activate_item(&existing, true, true, window, cx); + cx.observe_new(|workspace: &mut Workspace, _, _| { + workspace.register_action_renderer(|div, _, _, cx| { + let settings_ui_actions = [std::any::TypeId::of::()]; + let has_flag = cx.has_flag::(); + command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _| { + if has_flag { + filter.show_action_types(&settings_ui_actions); + } else { + filter.hide_action_types(&settings_ui_actions); + } + }); + if has_flag { + div.on_action(cx.listener(open_settings_editor)) } else { - let settings_page = SettingsPage::new(workspace, cx); - workspace.add_item_to_active_pane(Box::new(settings_page), None, true, window, cx) + div } }); - }); - - cx.observe_new(|_workspace: &mut Workspace, window, cx| { - let Some(window) = window else { - return; - }; - - let settings_ui_actions = [TypeId::of::()]; - - CommandPaletteFilter::update_global(cx, |filter, _cx| { - filter.hide_action_types(&settings_ui_actions); - }); - - cx.observe_flag::( - window, - move |is_enabled, _workspace, _, cx| { - if is_enabled { - CommandPaletteFilter::update_global(cx, |filter, _cx| { - filter.show_action_types(&settings_ui_actions); - }); - } else { - CommandPaletteFilter::update_global(cx, |filter, _cx| { - filter.hide_action_types(&settings_ui_actions); - }); - } - }, - ) - .detach(); }) .detach(); } @@ -153,9 +148,7 @@ struct UiEntry { next_sibling: Option, // expanded: bool, render: Option, - /// 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 usize>, + dynamic_render: Option, generate_items: Option<( SettingsUiItem, fn(&serde_json::Value, &App) -> Vec, @@ -198,6 +191,7 @@ fn build_tree_item( depth: usize, prev_index: Option, ) { + // let tree: HashMap; let index = tree.len(); tree.push(UiEntry { title: entry.title.into(), @@ -208,7 +202,7 @@ fn build_tree_item( total_descendant_range: index + 1..index + 1, render: None, next_sibling: None, - select_descendant: None, + dynamic_render: None, generate_items: None, }); if let Some(prev_index) = prev_index { @@ -230,12 +224,14 @@ fn build_tree_item( SettingsUiItem::Single(item) => { tree[index].render = Some(item); } - SettingsUiItem::Union(SettingsUiItemUnion { - options, - determine_option, - }) => { - tree[index].select_descendant = Some(determine_option); + SettingsUiItem::Union(dynamic_render) => { + // todo(settings_ui) take from item and store other fields instead of clone + // will also require replacing usage in render_recursive so it can know + // which options were actually rendered + let options = dynamic_render.options.clone(); + tree[index].dynamic_render = Some(dynamic_render); for option in options { + let Some(option) = option else { continue }; let prev_index = tree[index] .descendant_range .is_empty() @@ -316,13 +312,14 @@ impl SettingsUiTree { } // todo(settings_ui): handle dynamic nodes here let selected_descendant_index = child - .select_descendant - .map(|select_descendant| { + .dynamic_render + .as_ref() + .map(|dynamic_render| { read_settings_value_from_path( SettingsStore::global(cx).raw_default_settings(), ¤t_path, ) - .map(|value| select_descendant(value, cx)) + .map(|value| (dynamic_render.determine_option)(value, cx)) }) .and_then(|selected_descendant_index| { selected_descendant_index.map(|index| child.nth_descendant_index(tree, index)) @@ -383,146 +380,175 @@ fn render_content( let mut path = smallvec::smallvec![]; - fn render_recursive( - tree: &[UiEntry], - index: usize, - path: &mut SmallVec<[SharedString; 1]>, - mut element: Div, - // todo(settings_ui): can this be a ref without cx borrow issues? - fallback_path: &mut Option>, - window: &mut Window, - cx: &mut App, - ) -> Div { - let Some(child) = tree.get(index) else { - return element.child( - Label::new(SharedString::new_static("No settings found")).color(Color::Error), - ); - }; + return render_recursive( + &tree.entries, + tree.active_entry_index, + &mut path, + content, + &mut None, + true, + window, + cx, + ); +} +fn render_recursive( + tree: &[UiEntry], + index: usize, + path: &mut SmallVec<[SharedString; 1]>, + mut element: Div, + fallback_path: &mut Option>, + render_next_title: bool, + window: &mut Window, + cx: &mut App, +) -> Div { + let Some(child) = tree.get(index) else { + return element + .child(Label::new(SharedString::new_static("No settings found")).color(Color::Error)); + }; + + if render_next_title { 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.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; + // todo(settings_ui): subgroups? + let mut pushed_path = false; + 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()); } - // let fallback_path_copy = fallback_path.cloned(); - let settings_value = settings_value_from_settings_and_path( - path.clone(), - 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, select_descendant(settings_value.read(), cx)); - if let Some(descendant_index) = selected_descendant { - element = render_recursive( - tree, - descendant_index, - path, - element, - fallback_path, - window, - cx, - ); - } - } 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(); + pushed_path = true; + } + let settings_value = settings_value_from_settings_and_path( + path.clone(), + 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(dynamic_render) = child.dynamic_render.as_ref() { + let value = settings_value.read(); + let selected_index = (dynamic_render.determine_option)(value, cx); + element = element.child(div().child(render_toggle_button_group_inner( + settings_value.title.clone(), + dynamic_render.labels, + Some(selected_index), + { + let path = settings_value.path.clone(); + let defaults = dynamic_render.defaults.clone(); + move |idx, cx| { + if idx == selected_index { + return; + } + let default = defaults.get(idx).cloned().unwrap_or_default(); + SettingsValue::write_value(&path, default, cx); } - } - } else if let Some(child_render) = child.render.as_ref() { - element = element.child(div().child(render_item_single( - settings_value, - child_render, + }, + ))); + // we don't add descendants for unit options, so we adjust the selected index + // by the number of options we didn't add descendants for, to get the descendant index + let selected_descendant_index = selected_index + - dynamic_render.options[..selected_index] + .iter() + .filter(|option| option.is_none()) + .count(); + if dynamic_render.options[selected_index].is_some() + && let Some(descendant_index) = + child.nth_descendant_index(tree, selected_descendant_index) + { + element = render_recursive( + tree, + descendant_index, + path, + element, + fallback_path, + false, window, cx, - ))); - } else if let Some(child_index) = child.first_descendant_index() { - let mut index = Some(child_index); - while let Some(sub_child_index) = index { + ); + } + } 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( - tree, - sub_child_index, + &ui_items, + item_index, path, element, - fallback_path, + &mut Some(defaults_path.clone()), + true, window, cx, ); - index = tree[sub_child_index].next_sibling; + path.pop(); } - } else { - element = - element.child(div().child(Label::new("// skipped (for now)").color(Color::Muted))) } - - if pushed_path { - path.pop(); - if let Some(fallback_path) = fallback_path.as_mut() { - fallback_path.pop(); - } + } else if let Some(child_render) = child.render.as_ref() { + element = element.child(div().child(render_item_single( + settings_value, + child_render, + window, + cx, + ))); + } 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, + fallback_path, + true, + window, + cx, + ); + index = tree[sub_child_index].next_sibling; } - return element; + } else { + element = element.child(div().child(Label::new("// skipped (for now)").color(Color::Muted))) } - return render_recursive( - &tree.entries, - tree.active_entry_index, - &mut path, - content, - &mut None, - window, - cx, - ); + if pushed_path { + path.pop(); + if let Some(fallback_path) = fallback_path.as_mut() { + fallback_path.pop(); + } + } + return element; } impl Render for SettingsPage { @@ -855,41 +881,47 @@ fn render_toggle_button_group( _: &mut App, ) -> AnyElement { let value = downcast_any_item::(value); + let active_value = value.read(); + let selected_idx = variants.iter().position(|v| v == &active_value); + + return render_toggle_button_group_inner(value.title, labels, selected_idx, { + let path = value.path.clone(); + move |variant_index, cx| { + SettingsValue::write_value( + &path, + serde_json::Value::String(variants[variant_index].to_string()), + cx, + ); + } + }); +} +fn render_toggle_button_group_inner( + title: SharedString, + labels: &'static [&'static str], + selected_idx: Option, + on_write: impl Fn(usize, &mut App) + 'static, +) -> AnyElement { fn make_toggle_group( - value: SettingsValue, - variants: &'static [&'static str], + title: SharedString, + selected_idx: Option, + on_write: Rc, labels: &'static [&'static str], ) -> AnyElement { - let mut variants_array: [(&'static str, &'static str); LEN] = [("unused", "unused"); LEN]; - for i in 0..LEN { - variants_array[i] = (variants[i], labels[i]); - } - let active_value = value.read(); - - let selected_idx = variants_array - .iter() - .enumerate() - .find_map(|(idx, (variant, _))| { - if variant == &active_value { - Some(idx) - } else { - None - } - }); + let labels_array: [&'static str; LEN] = { + let mut arr = ["unused"; LEN]; + arr.copy_from_slice(labels); + arr + }; let mut idx = 0; ToggleButtonGroup::single_row( - value.title.clone(), - variants_array.map(|(variant, label)| { - let path = value.path.clone(); + title, + labels_array.map(|label| { idx += 1; + let on_write = on_write.clone(); ToggleButtonSimple::new(label, move |_, _, cx| { - SettingsValue::write_value( - &path, - serde_json::Value::String(variant.to_string()), - cx, - ); + on_write(idx - 1, cx); }) }), ) @@ -898,10 +930,12 @@ fn render_toggle_button_group( .into_any_element() } + let on_write = Rc::new(on_write); + macro_rules! templ_toggl_with_const_param { ($len:expr) => { - if variants.len() == $len { - return make_toggle_group::<$len>(value, variants, labels); + if labels.len() == $len { + return make_toggle_group::<$len>(title.clone(), selected_idx, on_write, labels); } }; } diff --git a/crates/settings_ui_macros/src/settings_ui_macros.rs b/crates/settings_ui_macros/src/settings_ui_macros.rs index 076f9c0f04e2963e9f4732a1fc7177f9ab85c723..fcff230ef8e07b551ea769c5ab94643ef715954d 100644 --- a/crates/settings_ui_macros/src/settings_ui_macros.rs +++ b/crates/settings_ui_macros/src/settings_ui_macros.rs @@ -1,5 +1,3 @@ -use std::ops::Not; - use heck::{ToSnakeCase as _, ToTitleCase as _}; use proc_macro2::TokenStream; use quote::{ToTokens, quote}; @@ -67,7 +65,7 @@ pub fn derive_settings_ui(input: proc_macro::TokenStream) -> proc_macro::TokenSt let doc_str = parse_documentation_from_attrs(&input.attrs); - let ui_item_fn_body = generate_ui_item_body(group_name.as_ref(), path_name.as_ref(), &input); + let ui_item_fn_body = generate_ui_item_body(group_name.as_ref(), &input); // todo(settings_ui): make group name optional, repurpose group as tag indicating item is group, and have "title" tag for custom title let title = group_name.unwrap_or(input.ident.to_string().to_title_case()); @@ -126,109 +124,221 @@ fn map_ui_item_to_entry( doc_str: Option<&str>, ty: TokenStream, ) -> TokenStream { - let ty = extract_type_from_option(ty); // todo(settings_ui): does quote! just work with options? let path = path.map_or_else(|| quote! {None}, |path| quote! {Some(#path)}); let doc_str = doc_str.map_or_else(|| quote! {None}, |doc_str| quote! {Some(#doc_str)}); + let item = ui_item_from_type(ty); quote! { settings::SettingsUiEntry { title: #title, path: #path, - item: #ty::settings_ui_item(), + item: #item, documentation: #doc_str, } } } -fn generate_ui_item_body( - group_name: Option<&String>, - path_name: Option<&String>, - input: &syn::DeriveInput, +fn ui_item_from_type(ty: TokenStream) -> TokenStream { + let ty = extract_type_from_option(ty); + return trait_method_call(ty, quote! {settings::SettingsUi}, quote! {settings_ui_item}); +} + +fn trait_method_call( + ty: TokenStream, + trait_name: TokenStream, + method_name: TokenStream, ) -> TokenStream { - match (group_name, path_name, &input.data) { - (_, _, Data::Union(_)) => unimplemented!("Derive SettingsUi for Unions"), - (None, _, Data::Struct(_)) => quote! { + // doing the makes the error message better: + // -> "#ty Doesn't implement settings::SettingsUi" instead of "no item "settings_ui_item" for #ty" + // and ensures safety against name conflicts + // + // todo(settings_ui): Turn `Vec` into `Vec::` here as well + quote! { + <#ty as #trait_name>::#method_name() + } +} + +fn generate_ui_item_body(group_name: Option<&String>, input: &syn::DeriveInput) -> TokenStream { + match (group_name, &input.data) { + (_, Data::Union(_)) => unimplemented!("Derive SettingsUi for Unions"), + (None, Data::Struct(_)) => quote! { settings::SettingsUiItem::None }, - (Some(_), _, Data::Struct(data_struct)) => { - let struct_serde_attrs = parse_serde_attributes(&input.attrs); - let fields = data_struct - .fields - .iter() - .filter(|field| { - !field.attrs.iter().any(|attr| { - let mut has_skip = false; - if attr.path().is_ident("settings_ui") { - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("skip") { - has_skip = true; - } - Ok(()) - }); - } - - has_skip - }) - }) - .map(|field| { - let field_serde_attrs = parse_serde_attributes(&field.attrs); - let name = field.ident.clone().expect("tuple fields").to_string(); - let doc_str = parse_documentation_from_attrs(&field.attrs); - - ( - name.to_title_case(), - doc_str, - field_serde_attrs.flatten.not().then(|| { - struct_serde_attrs.apply_rename_to_field(&field_serde_attrs, &name) - }), - field.ty.to_token_stream(), - ) - }) - // todo(settings_ui): Re-format field name as nice title, and support setting different title with attr - .map(|(title, doc_str, path, ty)| { - map_ui_item_to_entry(path.as_deref(), &title, doc_str.as_deref(), ty) - }); - - quote! { - settings::SettingsUiItem::Group(settings::SettingsUiItemGroup{ items: vec![#(#fields),*] }) - } + (Some(_), Data::Struct(data_struct)) => { + let parent_serde_attrs = parse_serde_attributes(&input.attrs); + item_group_from_fields(&data_struct.fields, &parent_serde_attrs) } - (None, _, Data::Enum(data_enum)) => { + (None, Data::Enum(data_enum)) => { let serde_attrs = parse_serde_attributes(&input.attrs); let length = data_enum.variants.len(); - let variants = data_enum.variants.iter().map(|variant| { - let string = variant.ident.clone().to_string(); + let mut variants = Vec::with_capacity(length); + let mut labels = Vec::with_capacity(length); - let title = string.to_title_case(); - let string = serde_attrs.rename_all.apply(&string); + for variant in &data_enum.variants { + // todo(settings_ui): Can #[serde(rename = )] be on enum variants? + let ident = variant.ident.clone().to_string(); + let variant_name = serde_attrs.rename_all.apply(&ident); + let title = variant_name.to_title_case(); - (string, title) - }); + variants.push(variant_name); + labels.push(title); + } - let (variants, labels): (Vec<_>, Vec<_>) = variants.unzip(); + let is_not_union = data_enum.variants.iter().all(|v| v.fields.is_empty()); + if is_not_union { + return if length > 6 { + quote! { + settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::DropDown{ variants: &[#(#variants),*], labels: &[#(#labels),*] }) + } + } else { + quote! { + settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::ToggleGroup{ variants: &[#(#variants),*], labels: &[#(#labels),*] }) + } + }; + } + // else: Union! + let enum_name = &input.ident; - if length > 6 { - quote! { - settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::DropDown{ variants: &[#(#variants),*], labels: &[#(#labels),*] }) + let options = data_enum.variants.iter().map(|variant| { + if variant.fields.is_empty() { + return quote! {None}; } - } else { + let name = &variant.ident; + let item = item_group_from_fields(&variant.fields, &serde_attrs); + // todo(settings_ui): documentation + return quote! { + Some(settings::SettingsUiEntry { + path: None, + title: stringify!(#name), + documentation: None, + item: #item, + }) + }; + }); + let defaults = data_enum.variants.iter().map(|variant| { + let variant_name = &variant.ident; + if variant.fields.is_empty() { + quote! { + serde_json::to_value(#enum_name::#variant_name).expect("Failed to serialize default value for #enum_name::#variant_name") + } + } else { + let fields = variant.fields.iter().enumerate().map(|(index, field)| { + let field_name = field.ident.as_ref().map_or_else(|| syn::Index::from(index).into_token_stream(), |ident| ident.to_token_stream()); + let field_type_is_option = option_inner_type(field.ty.to_token_stream()).is_some(); + let field_default = if field_type_is_option { + quote! { + None + } + } else { + quote! { + ::std::default::Default::default() + } + }; + + quote!{ + #field_name: #field_default + } + }); + quote! { + serde_json::to_value(#enum_name::#variant_name { + #(#fields),* + }).expect("Failed to serialize default value for #enum_name::#variant_name") + } + } + }); + // todo(settings_ui): Identify #[default] attr and use it for index, defaulting to 0 + let default_variant_index: usize = 0; + let determine_option_fn = { + let match_arms = data_enum + .variants + .iter() + .enumerate() + .map(|(index, variant)| { + let variant_name = &variant.ident; + quote! { + Ok(#variant_name {..}) => #index + } + }); quote! { - settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::ToggleGroup{ variants: &[#(#variants),*], labels: &[#(#labels),*] }) + |value: &serde_json::Value, _cx: &gpui::App| -> usize { + use #enum_name::*; + match serde_json::from_value::<#enum_name>(value.clone()) { + #(#match_arms),*, + Err(_) => #default_variant_index, + } + } } - } + }; + // todo(settings_ui) should probably always use toggle group for unions, dropdown makes less sense + return quote! { + settings::SettingsUiItem::Union(settings::SettingsUiItemUnion { + defaults: Box::new([#(#defaults),*]), + labels: &[#(#labels),*], + options: Box::new([#(#options),*]), + determine_option: #determine_option_fn, + }) + }; + // panic!("Unhandled"); } // todo(settings_ui) discriminated unions - (_, _, Data::Enum(_)) => quote! { + (_, Data::Enum(_)) => quote! { settings::SettingsUiItem::None }, } } +fn item_group_from_fields(fields: &syn::Fields, parent_serde_attrs: &SerdeOptions) -> TokenStream { + let group_items = fields + .iter() + .filter(|field| { + !field.attrs.iter().any(|attr| { + let mut has_skip = false; + if attr.path().is_ident("settings_ui") { + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("skip") { + has_skip = true; + } + Ok(()) + }); + } + + has_skip + }) + }) + .map(|field| { + let field_serde_attrs = parse_serde_attributes(&field.attrs); + let name = field.ident.as_ref().map(ToString::to_string); + let title = name.as_ref().map_or_else( + || "todo(settings_ui): Titles for tuple fields".to_string(), + |name| name.to_title_case(), + ); + let doc_str = parse_documentation_from_attrs(&field.attrs); + + ( + title, + doc_str, + name.filter(|_| !field_serde_attrs.flatten).map(|name| { + parent_serde_attrs.apply_rename_to_field(&field_serde_attrs, &name) + }), + field.ty.to_token_stream(), + ) + }) + // todo(settings_ui): Re-format field name as nice title, and support setting different title with attr + .map(|(title, doc_str, path, ty)| { + map_ui_item_to_entry(path.as_deref(), &title, doc_str.as_deref(), ty) + }); + + quote! { + settings::SettingsUiItem::Group(settings::SettingsUiItemGroup{ items: vec![#(#group_items),*] }) + } +} + struct SerdeOptions { rename_all: SerdeRenameAll, rename: Option, flatten: bool, + untagged: bool, _alias: Option, // todo(settings_ui) } @@ -264,6 +374,7 @@ fn parse_serde_attributes(attrs: &[syn::Attribute]) -> SerdeOptions { rename_all: SerdeRenameAll::None, rename: None, flatten: false, + untagged: false, _alias: None, }; @@ -296,6 +407,8 @@ fn parse_serde_attributes(attrs: &[syn::Attribute]) -> SerdeOptions { meta.input.parse::()?; let lit = meta.input.parse::()?.value(); options.rename = Some(lit); + } else if meta.path.is_ident("untagged") { + options.untagged = true; } Ok(()) }) diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index 073911fd60d441c38c361144e033591b3eed433a..27802888c90b29d7998d47c1668c4015d307476d 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -456,7 +456,7 @@ Project panel can be shown/hidden with {#action project_panel::ToggleFocus} ({#k "button": true, // Show/hide the icon in the status bar "dock": "right", // Where to dock: left, right, bottom "default_width": 640, // Default width (left/right docked) - "default_height": 320, // Default height (bottom dockeed) + "default_height": 320, // Default height (bottom docked) }, "agent_font_size": 16 ``` @@ -471,7 +471,7 @@ See [Zed AI Documentation](./ai/overview.md) for additional non-visual AI settin "dock": "bottom", // Where to dock: left, right, bottom "button": true, // Show/hide status bar icon "default_width": 640, // Default width (left/right docked) - "default_height": 320, // Default height (bottom dockeed) + "default_height": 320, // Default height (bottom docked) // Set the cursor blinking behavior in the terminal (on, off, terminal_controlled) "blinking": "terminal_controlled",