From 8a8a9a4f079ef5a66252ad73d01476d45891e647 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 1 Sep 2025 16:53:43 -0500 Subject: [PATCH] settings_ui: Add dynamic settings UI item (#37331) 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 ... --- crates/settings/src/settings_ui.rs | 9 ++ crates/settings_ui/src/settings_ui.rs | 113 ++++++++++++------ .../src/settings_ui_macros.rs | 6 +- 3 files changed, 93 insertions(+), 35 deletions(-) diff --git a/crates/settings/src/settings_ui.rs b/crates/settings/src/settings_ui.rs index 8b30ebc9d5968943d3814f7569d1367d389e386a..40ac3d9db9f82625f58007b182d6fb2ffb43a648 100644 --- a/crates/settings/src/settings_ui.rs +++ b/crates/settings/src/settings_ui.rs @@ -29,6 +29,11 @@ pub enum SettingsUiEntryVariant { path: &'static str, item: SettingsUiItemSingle, }, + Dynamic { + path: &'static str, + options: Vec, + determine_option: fn(&serde_json::Value, &mut App) -> usize, + }, // todo(settings_ui): remove None, } @@ -90,6 +95,10 @@ pub enum SettingsUiItem { items: Vec, }, Single(SettingsUiItemSingle), + Dynamic { + options: Vec, + determine_option: fn(&serde_json::Value, &mut App) -> usize, + }, None, } diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index ae03170a1a9a2cb3e53c67402c95c8e79e739ab9..37edfd5679d259b1d81159f02472fc682bf17243 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/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, // expanded: bool, render: Option, + select_descendant: Option usize>, +} + +impl UiEntry { + fn first_descendant_index(&self) -> Option { + return self + .descendant_range + .is_empty() + .not() + .then_some(self.descendant_range.start); + } + + fn nth_descendant_index(&self, tree: &[UiEntry], n: usize) -> Option { + 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, - entries: Vec, + entries: Vec, active_entry_index: usize, } fn build_tree_item( - tree: &mut Vec, - group: SettingsUiEntryVariant, + tree: &mut Vec, + entry: SettingsUiEntryVariant, depth: usize, prev_index: Option, ) { 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, ) -> 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); }; diff --git a/crates/settings_ui_macros/src/settings_ui_macros.rs b/crates/settings_ui_macros/src/settings_ui_macros.rs index 6e37745a7c24155de631e47ffc8c265209ee24e8..5250febe98cb17c74cf03d909e430b1415e29569 100644 --- a/crates/settings_ui_macros/src/settings_ui_macros.rs +++ b/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, } }