settings_ui: Handle enums with fields (#37945)

Ben Kunkle and Conrad created

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 <conrad@zed.dev>

Change summary

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 ++++++++------
crates/settings_ui_macros/src/settings_ui_macros.rs | 251 ++++++--
docs/src/visual-customization.md                    |   4 
6 files changed, 459 insertions(+), 287 deletions(-)

Detailed changes

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<T: FeatureFlag>(&self) -> bool {
         self.try_global::<FeatureFlags>()
             .map(|flags| flags.has_flag::<T>())
-            .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 {

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<Formatter>),
+    Vec(#[settings_ui(skip)] Vec<Formatter>),
+}
+
+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<String> },
+    LanguageServer {
+        #[settings_ui(skip)]
+        name: Option<String>,
+    },
     /// 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<str>,
         /// The arguments to pass to the program.
+        #[settings_ui(skip)]
         arguments: Option<Arc<[String]>>,
     },
     /// Files should be formatted using code actions executed by language servers.
-    CodeActions(HashMap<String, bool>),
+    CodeActions(#[settings_ui(skip)] HashMap<String, bool>),
 }
 
 /// The settings for indent guides.

crates/settings/src/settings_ui_core.rs 🔗

@@ -108,10 +108,19 @@ impl<T: serde::Serialize> SettingsValue<T> {
 
 #[derive(Clone)]
 pub struct SettingsUiItemUnion {
-    pub options: Vec<SettingsUiEntry>,
+    /// 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<SettingsUiEntry>]>,
     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,
 }
 

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<Workspace>,
+) {
+    // 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::<SettingsPage>());
+
+    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::<SettingsPage>());
-
-            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::<OpenSettingsEditor>()];
+            let has_flag = cx.has_flag::<SettingsUiFeatureFlag>();
+            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::<OpenSettingsEditor>()];
-
-        CommandPaletteFilter::update_global(cx, |filter, _cx| {
-            filter.hide_action_types(&settings_ui_actions);
-        });
-
-        cx.observe_flag::<SettingsUiFeatureFlag, _>(
-            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<usize>,
     // expanded: bool,
     render: Option<SettingsUiItemSingle>,
-    /// 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>,
+    dynamic_render: Option<SettingsUiItemUnion>,
     generate_items: Option<(
         SettingsUiItem,
         fn(&serde_json::Value, &App) -> Vec<SettingsUiEntryMetaData>,
@@ -198,6 +191,7 @@ fn build_tree_item(
     depth: usize,
     prev_index: Option<usize>,
 ) {
+    // let tree: HashMap<Path, UiEntry>;
     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(),
                         &current_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<SmallVec<[SharedString; 1]>>,
-        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<SmallVec<[SharedString; 1]>>,
+    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::<String>(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<usize>,
+    on_write: impl Fn(usize, &mut App) + 'static,
+) -> AnyElement {
     fn make_toggle_group<const LEN: usize>(
-        value: SettingsValue<String>,
-        variants: &'static [&'static str],
+        title: SharedString,
+        selected_idx: Option<usize>,
+        on_write: Rc<dyn Fn(usize, &mut App)>,
         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);
             }
         };
     }

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 <ty as settings::SettingsUi> 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<T>` into `Vec::<T>` 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<String>,
     flatten: bool,
+    untagged: bool,
     _alias: Option<String>, // 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::<Token![=]>()?;
                 let lit = meta.input.parse::<LitStr>()?.value();
                 options.rename = Some(lit);
+            } else if meta.path.is_ident("untagged") {
+                options.untagged = true;
             }
             Ok(())
         })

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",