diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index d74244131e6635c7b9eda6ace0723ced96b0e041..7f4d024e57c4831aa4c512e6dcb3a9ab35d4f610 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -748,6 +748,7 @@ pub struct ScrollbarAxesContent { #[derive( Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi, )] +#[settings_ui(group = "Gutter")] pub struct GutterContent { /// Whether to show line numbers in the gutter. /// diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 3443ccf592a4138edb61959f0dd82bdb8cc8d418..cb519e32eca964cee4a742085714b233a424dd3c 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -208,7 +208,9 @@ impl LanguageSettings { } /// The provider that supplies edit predictions. -#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive( + Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema, SettingsUi, +)] #[serde(rename_all = "snake_case")] pub enum EditPredictionProvider { None, @@ -231,13 +233,14 @@ impl EditPredictionProvider { /// The settings for edit predictions, such as [GitHub Copilot](https://github.com/features/copilot) /// or [Supermaven](https://supermaven.com). -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, SettingsUi)] pub struct EditPredictionSettings { /// The provider that supplies edit predictions. pub provider: EditPredictionProvider, /// A list of globs representing files that edit predictions should be disabled for. /// This list adds to a pre-existing, sensible default set of globs. /// Any additional ones you add are combined with them. + #[settings_ui(skip)] pub disabled_globs: Vec, /// Configures how edit predictions are displayed in the buffer. pub mode: EditPredictionsMode, @@ -269,7 +272,9 @@ pub struct DisabledGlob { } /// The mode in which edit predictions should be displayed. -#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive( + Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema, SettingsUi, +)] #[serde(rename_all = "snake_case")] pub enum EditPredictionsMode { /// If provider supports it, display inline when holding modifier key (e.g., alt). @@ -282,13 +287,15 @@ pub enum EditPredictionsMode { Eager, } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, SettingsUi)] pub struct CopilotSettings { /// HTTP/HTTPS proxy to use for Copilot. + #[settings_ui(skip)] pub proxy: Option, /// Disable certificate verification for proxy (not recommended). pub proxy_no_verify: Option, /// Enterprise URI for Copilot. + #[settings_ui(skip)] pub enterprise_uri: Option, } @@ -297,6 +304,7 @@ pub struct CopilotSettings { Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, SettingsUi, SettingsKey, )] #[settings_key(None)] +#[settings_ui(group = "Default Language Settings")] pub struct AllLanguageSettingsContent { /// The settings for enabling/disabling features. #[serde(default)] @@ -309,10 +317,12 @@ pub struct AllLanguageSettingsContent { pub defaults: LanguageSettingsContent, /// The settings for individual languages. #[serde(default)] + #[settings_ui(skip)] pub languages: LanguageToSettingsMap, /// Settings for associating file extensions and filenames /// with languages. #[serde(default)] + #[settings_ui(skip)] pub file_types: HashMap, Vec>, } @@ -345,7 +355,7 @@ inventory::submit! { } /// Controls how completions are processed for this language. -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi)] #[serde(rename_all = "snake_case")] pub struct CompletionSettings { /// Controls how words are completed. @@ -420,7 +430,7 @@ fn default_3() -> usize { } /// The settings for a particular language. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema, SettingsUi)] pub struct LanguageSettingsContent { /// How many columns a tab should occupy. /// @@ -617,12 +627,13 @@ pub enum RewrapBehavior { } /// The contents of the edit prediction settings. -#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, SettingsUi)] pub struct EditPredictionSettingsContent { /// A list of globs representing files that edit predictions should be disabled for. /// This list adds to a pre-existing, sensible default set of globs. /// Any additional ones you add are combined with them. #[serde(default)] + #[settings_ui(skip)] pub disabled_globs: Option>, /// The mode used to display edit predictions in the buffer. /// Provider support required. @@ -637,12 +648,13 @@ pub struct EditPredictionSettingsContent { pub enabled_in_text_threads: bool, } -#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, SettingsUi)] pub struct CopilotSettingsContent { /// HTTP/HTTPS proxy to use for Copilot. /// /// Default: none #[serde(default)] + #[settings_ui(skip)] pub proxy: Option, /// Disable certificate verification for the proxy (not recommended). /// @@ -653,19 +665,21 @@ pub struct CopilotSettingsContent { /// /// Default: none #[serde(default)] + #[settings_ui(skip)] pub enterprise_uri: Option, } /// The settings for enabling/disabling features. -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, SettingsUi)] #[serde(rename_all = "snake_case")] +#[settings_ui(group = "Features")] pub struct FeaturesContent { /// Determines which edit prediction provider to use. pub edit_prediction_provider: Option, } /// Controls the soft-wrapping behavior in the editor. -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi)] #[serde(rename_all = "snake_case")] pub enum SoftWrap { /// Prefer a single line generally, unless an overly long line is encountered. @@ -934,7 +948,9 @@ pub enum Formatter { } /// The settings for indent guides. -#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive( + Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, SettingsUi, +)] pub struct IndentGuideSettings { /// Whether to display indent guides in the editor. /// diff --git a/crates/settings/src/settings_ui_core.rs b/crates/settings/src/settings_ui_core.rs index 9086d3c7454465e8abcaf2d30d01a4f928e4ddef..896a8bc038bdd8e495cdb6161212f9c722d54f14 100644 --- a/crates/settings/src/settings_ui_core.rs +++ b/crates/settings/src/settings_ui_core.rs @@ -19,6 +19,7 @@ pub trait SettingsUi { path: None, title: "None entry", item: SettingsUiItem::None, + documentation: None, } } } @@ -29,6 +30,8 @@ pub struct SettingsUiEntry { pub path: Option<&'static str>, /// What is displayed for the text for this entry pub title: &'static str, + /// documentation for this entry. Constructed from the documentation comment above the struct or field + pub documentation: Option<&'static str>, pub item: SettingsUiItem, } @@ -54,6 +57,7 @@ pub enum SettingsUiItemSingle { pub struct SettingsValue { pub title: &'static str, + pub documentation: Option<&'static str>, pub path: SmallVec<[&'static str; 1]>, pub value: Option, pub default_value: T, @@ -128,7 +132,9 @@ pub enum NumType { U64 = 0, U32 = 1, F32 = 2, + USIZE = 3, } + pub static NUM_TYPE_NAMES: std::sync::LazyLock<[&'static str; NumType::COUNT]> = std::sync::LazyLock::new(|| NumType::ALL.map(NumType::type_name)); pub static NUM_TYPE_IDS: std::sync::LazyLock<[TypeId; NumType::COUNT]> = @@ -143,6 +149,7 @@ impl NumType { NumType::U64 => TypeId::of::(), NumType::U32 => TypeId::of::(), NumType::F32 => TypeId::of::(), + NumType::USIZE => TypeId::of::(), } } @@ -151,6 +158,7 @@ impl NumType { NumType::U64 => std::any::type_name::(), NumType::U32 => std::any::type_name::(), NumType::F32 => std::any::type_name::(), + NumType::USIZE => std::any::type_name::(), } } } @@ -175,3 +183,4 @@ numeric_stepper_for_num_type!(u64, U64); numeric_stepper_for_num_type!(u32, U32); // todo(settings_ui) is there a better ui for f32? numeric_stepper_for_num_type!(f32, F32); +numeric_stepper_for_num_type!(usize, USIZE); diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index f316a318785c7f56d465c2d39e6b6ea9bbbd1bfa..d736f0e174ba13d368794d8f5b623a44845d561b 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -1,14 +1,13 @@ mod appearance_settings_controls; use std::any::TypeId; -use std::collections::VecDeque; use std::ops::{Not, Range}; use anyhow::Context as _; use command_palette_hooks::CommandPaletteFilter; use editor::EditorSettingsControls; use feature_flags::{FeatureFlag, FeatureFlagViewExt}; -use gpui::{App, Entity, EventEmitter, FocusHandle, Focusable, ReadGlobal, actions}; +use gpui::{App, Entity, EventEmitter, FocusHandle, Focusable, ReadGlobal, ScrollHandle, actions}; use settings::{ NumType, SettingsStore, SettingsUiEntry, SettingsUiItem, SettingsUiItemDynamic, SettingsUiItemGroup, SettingsUiItemSingle, SettingsValue, @@ -138,6 +137,7 @@ impl Item for SettingsPage { struct UiEntry { title: &'static str, path: Option<&'static str>, + documentation: Option<&'static str>, _depth: usize, // a // b < a descendant range < a total descendant range @@ -195,6 +195,7 @@ fn build_tree_item( tree.push(UiEntry { title: entry.title, path: entry.path, + documentation: entry.documentation, _depth: depth, descendant_range: index + 1..index + 1, total_descendant_range: index + 1..index + 1, @@ -354,32 +355,29 @@ fn render_content( tree: &SettingsUiTree, window: &mut Window, cx: &mut Context, -) -> impl IntoElement { - let Some(active_entry) = tree.entries.get(tree.active_entry_index) else { - return div() - .size_full() - .child(Label::new(SharedString::new_static("No settings found")).color(Color::Error)); - }; - let mut content = v_flex().size_full().gap_4().overflow_hidden(); +) -> Div { + let content = v_flex().size_full().gap_4(); let mut path = smallvec::smallvec![]; - if let Some(active_entry_path) = active_entry.path { - path.push(active_entry_path); - } - let mut entry_index_queue = VecDeque::new(); - - if let Some(child_index) = active_entry.first_descendant_index() { - entry_index_queue.push_back(child_index); - let mut index = child_index; - while let Some(next_sibling_index) = tree.entries[index].next_sibling { - entry_index_queue.push_back(next_sibling_index); - index = next_sibling_index; - } - }; - while let Some(index) = entry_index_queue.pop_front() { + fn render_recursive( + tree: &SettingsUiTree, + index: usize, + path: &mut SmallVec<[&'static str; 1]>, + mut element: Div, + window: &mut Window, + cx: &mut App, + ) -> Div { + let Some(child) = tree.entries.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)); + // todo(settings_ui): subgroups? - let child = &tree.entries[index]; let mut pushed_path = false; if let Some(child_path) = child.path { path.push(child_path); @@ -388,37 +386,56 @@ fn render_content( let settings_value = settings_value_from_settings_and_path( path.clone(), child.title, + child.documentation, // 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 = select_descendant(settings_value.read(), cx); - if let Some(descendant_index) = - child.nth_descendant_index(&tree.entries, selected_descendant) - { - entry_index_queue.push_front(descendant_index); + let selected_descendant = child + .nth_descendant_index(&tree.entries, select_descendant(settings_value.read(), cx)); + if let Some(descendant_index) = selected_descendant { + element = render_recursive(&tree, descendant_index, path, element, window, cx); } } + 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, window, cx); + index = tree.entries[sub_child_index].next_sibling; + } + } else { + element = + element.child(div().child(Label::new("// skipped (for now)").color(Color::Muted))) + } + if pushed_path { path.pop(); } - let Some(child_render) = child.render.as_ref() else { - continue; - }; - content = content.child( - div() - .child(Label::new(SharedString::new_static(child.title)).size(LabelSize::Large)) - .child(render_item_single(settings_value, child_render, window, cx)), - ); + return element; } - return content; + return render_recursive( + tree, + tree.active_entry_index, + &mut path, + content, + window, + cx, + ); } impl Render for SettingsPage { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let scroll_handle = window.use_state(cx, |_, _| ScrollHandle::new()); div() .grid() .grid_cols(16) @@ -427,15 +444,19 @@ impl Render for SettingsPage { .size_full() .child( div() + .id("settings-ui-nav") .col_span(2) .h_full() .child(render_nav(&self.settings_tree, window, cx)), ) - .child(div().col_span(4).h_full().child(render_content( - &self.settings_tree, - window, - cx, - ))) + .child( + div().col_span(6).h_full().child( + render_content(&self.settings_tree, window, cx) + .id("settings-ui-content") + .track_scroll(scroll_handle.read(cx)) + .overflow_y_scroll(), + ), + ) } } @@ -530,6 +551,7 @@ fn downcast_any_item( let deserialized_setting_value = SettingsValue { title: settings_value.title, path: settings_value.path, + documentation: settings_value.documentation, value, default_value, }; @@ -586,6 +608,17 @@ fn render_any_numeric_stepper( window, cx, ), + NumType::USIZE => render_numeric_stepper::( + downcast_any_item(settings_value), + usize::saturating_sub, + usize::saturating_add, + |n| { + serde_json::Number::try_from(n) + .context("Failed to convert usize to serde_json::Number") + }, + window, + cx, + ), } } @@ -640,7 +673,7 @@ fn render_switch_field( SwitchField::new( id, SharedString::new_static(value.title), - None, + value.documentation.map(SharedString::new_static), match value.read() { true => ToggleState::Selected, false => ToggleState::Unselected, @@ -731,6 +764,7 @@ fn render_toggle_button_group( fn settings_value_from_settings_and_path( path: SmallVec<[&'static str; 1]>, title: &'static str, + documentation: Option<&'static str>, user_settings: &serde_json::Value, default_settings: &serde_json::Value, ) -> SettingsValue { @@ -743,6 +777,7 @@ fn settings_value_from_settings_and_path( let settings_value = SettingsValue { default_value, value, + documentation, path: path.clone(), // todo(settings_ui) is title required inside SettingsValue? title, diff --git a/crates/settings_ui_macros/src/settings_ui_macros.rs b/crates/settings_ui_macros/src/settings_ui_macros.rs index 1895083508a6a606f4dd9889529719aa12ea0b10..076f9c0f04e2963e9f4732a1fc7177f9ab85c723 100644 --- a/crates/settings_ui_macros/src/settings_ui_macros.rs +++ b/crates/settings_ui_macros/src/settings_ui_macros.rs @@ -1,3 +1,5 @@ +use std::ops::Not; + use heck::{ToSnakeCase as _, ToTitleCase as _}; use proc_macro2::TokenStream; use quote::{ToTokens, quote}; @@ -63,12 +65,19 @@ 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); // 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()); - let ui_entry_fn_body = map_ui_item_to_entry(path_name.as_deref(), &title, quote! { Self }); + let ui_entry_fn_body = map_ui_item_to_entry( + path_name.as_deref(), + &title, + doc_str.as_deref(), + quote! { Self }, + ); let expanded = quote! { impl #impl_generics settings::SettingsUi for #name #ty_generics #where_clause { @@ -111,14 +120,22 @@ fn option_inner_type(ty: TokenStream) -> Option { return Some(ty.to_token_stream()); } -fn map_ui_item_to_entry(path: Option<&str>, title: &str, ty: TokenStream) -> TokenStream { +fn map_ui_item_to_entry( + path: Option<&str>, + title: &str, + 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)}); quote! { settings::SettingsUiEntry { title: #title, path: #path, item: #ty::settings_ui_item(), + documentation: #doc_str, } } } @@ -134,6 +151,7 @@ fn generate_ui_item_body( settings::SettingsUiItem::None }, (Some(_), _, Data::Struct(data_struct)) => { + let struct_serde_attrs = parse_serde_attributes(&input.attrs); let fields = data_struct .fields .iter() @@ -153,48 +171,37 @@ fn generate_ui_item_body( }) }) .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); + ( - field.ident.clone().expect("tuple fields").to_string(), + 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(|(name, ty)| map_ui_item_to_entry(Some(&name), &name.to_title_case(), ty)); + .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),*] }) } } (None, _, Data::Enum(data_enum)) => { - let mut lowercase = false; - let mut snake_case = false; - for attr in &input.attrs { - if attr.path().is_ident("serde") { - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("rename_all") { - meta.input.parse::()?; - let lit = meta.input.parse::()?.value(); - lowercase = lit == "lowercase"; - snake_case = lit == "snake_case"; - } - Ok(()) - }) - .ok(); - } - } + 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 title = string.to_title_case(); - let string = if lowercase { - string.to_lowercase() - } else if snake_case { - string.to_snake_case() - } else { - string - }; + let string = serde_attrs.rename_all.apply(&string); (string, title) }); @@ -218,6 +225,113 @@ fn generate_ui_item_body( } } +struct SerdeOptions { + rename_all: SerdeRenameAll, + rename: Option, + flatten: bool, + _alias: Option, // todo(settings_ui) +} + +#[derive(PartialEq)] +enum SerdeRenameAll { + Lowercase, + SnakeCase, + None, +} + +impl SerdeRenameAll { + fn apply(&self, name: &str) -> String { + match self { + SerdeRenameAll::Lowercase => name.to_lowercase(), + SerdeRenameAll::SnakeCase => name.to_snake_case(), + SerdeRenameAll::None => name.to_string(), + } + } +} + +impl SerdeOptions { + fn apply_rename_to_field(&self, field_options: &Self, name: &str) -> String { + // field renames take precedence over struct rename all cases + if let Some(rename) = &field_options.rename { + return rename.clone(); + } + return self.rename_all.apply(name); + } +} + +fn parse_serde_attributes(attrs: &[syn::Attribute]) -> SerdeOptions { + let mut options = SerdeOptions { + rename_all: SerdeRenameAll::None, + rename: None, + flatten: false, + _alias: None, + }; + + for attr in attrs { + if !attr.path().is_ident("serde") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") { + meta.input.parse::()?; + let lit = meta.input.parse::()?.value(); + + if options.rename_all != SerdeRenameAll::None { + return Err(meta.error("duplicate `rename_all` attribute")); + } else if lit == "lowercase" { + options.rename_all = SerdeRenameAll::Lowercase; + } else if lit == "snake_case" { + options.rename_all = SerdeRenameAll::SnakeCase; + } else { + return Err(meta.error(format!("invalid `rename_all` attribute: {}", lit))); + } + // todo(settings_ui): Other options? + } else if meta.path.is_ident("flatten") { + options.flatten = true; + } else if meta.path.is_ident("rename") { + if options.rename.is_some() { + return Err(meta.error("Can only have one rename attribute")); + } + + meta.input.parse::()?; + let lit = meta.input.parse::()?.value(); + options.rename = Some(lit); + } + Ok(()) + }) + .unwrap(); + } + + return options; +} + +fn parse_documentation_from_attrs(attrs: &[syn::Attribute]) -> Option { + let mut doc_str = Option::::None; + for attr in attrs { + if attr.path().is_ident("doc") { + // /// ... + // becomes + // #[doc = "..."] + use syn::{Expr::Lit, ExprLit, Lit::Str, Meta, MetaNameValue}; + if let Meta::NameValue(MetaNameValue { + value: + Lit(ExprLit { + lit: Str(ref lit_str), + .. + }), + .. + }) = attr.meta + { + let doc = lit_str.value(); + let doc_str = doc_str.get_or_insert_default(); + doc_str.push_str(doc.trim()); + doc_str.push('\n'); + } + } + } + return doc_str; +} + struct SettingsKey { key: Option, fallback_key: Option, @@ -290,7 +404,7 @@ pub fn derive_settings_key(input: proc_macro::TokenStream) -> proc_macro::TokenS if parsed_settings_key.is_some() && settings_key.is_some() { panic!("Duplicate #[settings_key] attribute"); } - settings_key = parsed_settings_key; + settings_key = settings_key.or(parsed_settings_key); } let Some(SettingsKey { key, fallback_key }) = settings_key else {