settings_ui: Add dynamic settings UI item (#37331)

Ben Kunkle created

Closes #ISSUE

Adds a first draft of a way for "Dynamic" settings items to be added,
where Dynamic means settings where multiple sets of options are possible
(i.e. discriminated union, rust enum, etc). The implementation is very
similar to that of `Group`, except that instead of rendering all of it's
descendants, it contains a function to determine _which_ descendant to
render, whether that be a single item or a nested group of items.
Currently this is done in a type-unsafe way with indices, a future
improvement could be to make the API more type safe, and easier to
manually implement correctly.

An example of a "Dynamic" setting is `theme`, where it can either be a
string of the desired theme name, or an object with `mode: "light" |
"dark" | "system"` as well as theme names for `light` and `dark`. In the
system implemented by this PR, this would become a dynamic settings UI
item, where option `0` is a single item, the theme name selector, and
option `1` is a group, containing items for the `mode`, and
`light`/`dark` options.

Release Notes:

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

Change summary

crates/settings/src/settings_ui.rs                  |   9 +
crates/settings_ui/src/settings_ui.rs               | 113 ++++++++++----
crates/settings_ui_macros/src/settings_ui_macros.rs |   6 
3 files changed, 93 insertions(+), 35 deletions(-)

Detailed changes

crates/settings/src/settings_ui.rs 🔗

@@ -29,6 +29,11 @@ pub enum SettingsUiEntryVariant {
         path: &'static str,
         item: SettingsUiItemSingle,
     },
+    Dynamic {
+        path: &'static str,
+        options: Vec<SettingsUiEntry>,
+        determine_option: fn(&serde_json::Value, &mut App) -> usize,
+    },
     // todo(settings_ui): remove
     None,
 }
@@ -90,6 +95,10 @@ pub enum SettingsUiItem {
         items: Vec<SettingsUiEntry>,
     },
     Single(SettingsUiItemSingle),
+    Dynamic {
+        options: Vec<SettingsUiEntry>,
+        determine_option: fn(&serde_json::Value, &mut App) -> usize,
+    },
     None,
 }
 

crates/settings_ui/src/settings_ui.rs 🔗

@@ -1,6 +1,7 @@
 mod appearance_settings_controls;
 
 use std::any::TypeId;
+use std::collections::VecDeque;
 use std::ops::{Not, Range};
 
 use anyhow::Context as _;
@@ -131,7 +132,7 @@ impl Item for SettingsPage {
 //  - there should be an index of text -> item mappings, for using fuzzy::match
 //   - Do we want to show the parent groups when a item is matched?
 
-struct UIEntry {
+struct UiEntry {
     title: &'static str,
     path: &'static str,
     _depth: usize,
@@ -147,22 +148,46 @@ struct UIEntry {
     next_sibling: Option<usize>,
     // expanded: bool,
     render: Option<SettingsUiItemSingle>,
+    select_descendant: Option<fn(&serde_json::Value, &mut App) -> usize>,
+}
+
+impl UiEntry {
+    fn first_descendant_index(&self) -> Option<usize> {
+        return self
+            .descendant_range
+            .is_empty()
+            .not()
+            .then_some(self.descendant_range.start);
+    }
+
+    fn nth_descendant_index(&self, tree: &[UiEntry], n: usize) -> Option<usize> {
+        let first_descendant_index = self.first_descendant_index()?;
+        let mut current_index = 0;
+        let mut current_descendant_index = Some(first_descendant_index);
+        while let Some(descendant_index) = current_descendant_index
+            && current_index < n
+        {
+            current_index += 1;
+            current_descendant_index = tree[descendant_index].next_sibling;
+        }
+        current_descendant_index
+    }
 }
 
 struct SettingsUiTree {
     root_entry_indices: Vec<usize>,
-    entries: Vec<UIEntry>,
+    entries: Vec<UiEntry>,
     active_entry_index: usize,
 }
 
 fn build_tree_item(
-    tree: &mut Vec<UIEntry>,
-    group: SettingsUiEntryVariant,
+    tree: &mut Vec<UiEntry>,
+    entry: SettingsUiEntryVariant,
     depth: usize,
     prev_index: Option<usize>,
 ) {
     let index = tree.len();
-    tree.push(UIEntry {
+    tree.push(UiEntry {
         title: "",
         path: "",
         _depth: depth,
@@ -170,11 +195,12 @@ fn build_tree_item(
         total_descendant_range: index + 1..index + 1,
         render: None,
         next_sibling: None,
+        select_descendant: None,
     });
     if let Some(prev_index) = prev_index {
         tree[prev_index].next_sibling = Some(index);
     }
-    match group {
+    match entry {
         SettingsUiEntryVariant::Group {
             path,
             title,
@@ -199,6 +225,24 @@ fn build_tree_item(
             tree[index].title = path;
             tree[index].render = Some(item);
         }
+        SettingsUiEntryVariant::Dynamic {
+            path,
+            options,
+            determine_option,
+        } => {
+            tree[index].path = path;
+            tree[index].select_descendant = Some(determine_option);
+            for option in options {
+                let prev_index = tree[index]
+                    .descendant_range
+                    .is_empty()
+                    .not()
+                    .then_some(tree[index].descendant_range.end - 1);
+                tree[index].descendant_range.end = tree.len() + 1;
+                build_tree_item(tree, option.item, depth + 1, prev_index);
+                tree[index].total_descendant_range.end = tree.len();
+            }
+        }
         SettingsUiEntryVariant::None => {
             return;
         }
@@ -265,27 +309,28 @@ fn render_content(
     window: &mut Window,
     cx: &mut Context<SettingsPage>,
 ) -> impl IntoElement {
-    let Some(entry) = tree.entries.get(tree.active_entry_index) else {
+    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();
 
-    let mut child_index = entry
-        .descendant_range
-        .is_empty()
-        .not()
-        .then_some(entry.descendant_range.start);
-    let mut path = smallvec::smallvec![entry.path];
+    let mut path = smallvec::smallvec![active_entry.path];
+    let mut entry_index_queue = VecDeque::new();
 
-    while let Some(index) = child_index {
-        let child = &tree.entries[index];
-        child_index = child.next_sibling;
-        if child.render.is_none() {
-            // todo(settings_ui): subgroups?
-            continue;
+    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() {
+        // todo(settings_ui): subgroups?
+        let child = &tree.entries[index];
         path.push(child.path);
         let settings_value = settings_value_from_settings_and_path(
             path.clone(),
@@ -294,24 +339,23 @@ fn render_content(
             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);
+            }
+        }
+        path.pop();
+        let Some(child_render) = child.render.as_ref() else {
+            continue;
+        };
         content = content.child(
             div()
-                .child(
-                    Label::new(SharedString::new_static(tree.entries[index].title))
-                        .size(LabelSize::Large)
-                        .when(tree.active_entry_index == index, |this| {
-                            this.color(Color::Selected)
-                        }),
-                )
-                .child(render_item_single(
-                    settings_value,
-                    child.render.as_ref().unwrap(),
-                    window,
-                    cx,
-                )),
+                .child(Label::new(SharedString::new_static(child.title)).size(LabelSize::Large))
+                .child(render_item_single(settings_value, child_render, window, cx)),
         );
-
-        path.pop();
     }
 
     return content;
@@ -405,6 +449,7 @@ fn read_settings_value_from_path<'a>(
     settings_contents: &'a serde_json::Value,
     path: &[&'static str],
 ) -> Option<&'a serde_json::Value> {
+    // todo(settings_ui) make non recursive, and move to `settings` alongside SettingsValue, and add method to SettingsValue to get nested
     let Some((key, remaining)) = path.split_first() else {
         return Some(settings_contents);
     };

crates/settings_ui_macros/src/settings_ui_macros.rs 🔗

@@ -12,7 +12,6 @@ use syn::{Data, DeriveInput, LitStr, Token, parse_macro_input};
 ///
 /// ```
 /// use settings::SettingsUi;
-/// use settings_ui_macros::SettingsUi;
 ///
 /// #[derive(SettingsUi)]
 /// #[settings_ui(group = "Standard")]
@@ -102,6 +101,11 @@ fn map_ui_item_to_render(path: &str, ty: TokenStream) -> TokenStream {
                     path: #path,
                     item,
                 },
+                settings::SettingsUiItem::Dynamic{ options, determine_option } => settings::SettingsUiEntryVariant::Dynamic {
+                    path: #path,
+                    options,
+                    determine_option,
+                },
                 settings::SettingsUiItem::None => settings::SettingsUiEntryVariant::None,
             }
         }