Repository: zng-ui/zng (language: rust)

Issue:
Implement config metadata
Service should provide a StateMap associated with each config key that can be populated with metadata that can be used by a config editor widget to generate UI from the config.
Next change after config reset is ignored
#### Following these steps:

1 - Run config example.
2 - Click "Checked: false" button.
3 - Click reset for "Checked" config.
4 - Click "Checked: false" button again.

#### I saw this happen:

Reset button remains disabled. Also with two processes the second process does not update.

#### I expected this to happen:

Same behavior as first click.



Discussion / hints:
Or we can have a `SETTINGS` service that represents the CONFIG that is user editable.

```rust
use zng::{config::ConfigKey, prelude::*, prelude_wgt::*};

pub async fn window() -> window::WindowRoot {
    // l10n-settings-### Settings

    Window! {
        id = "primary-window";
        title = zng::env::about().app.clone();
        icon = shared::res::ICON_SMALL;
    }
}

struct Setting {
    category: cat::Key,
    config: ConfigKey,

    name: BoxedVar<Txt>,
    help: BoxedVar<Txt>,
    editor: WidgetFn<ConfigKey>,
}

app_local! {
    static SETTINGS_SV: bool = true;
}

/// Setting categories.
pub mod cat {
    use core::fmt;

    use zng::{
        l10n::l10n,
        text::Txt,
        var::{BoxedVar, Var as _},
    };

    // l10n-settings-### Settings Categories

    pub fn get(id: &str) -> Option<Category> {
        match id.strip_prefix("t-app-t/")? {
            "general" => Some(general()),
            _ => None,
        }
    }

    pub fn general() -> Category {
        Category {
            id: "t-app-t/general".into(),
            name: l10n!("settings/cat-general.name", "General").boxed(),
            help: l10n!("settings/cat-general.help", "General settings").boxed(),
        }
    }

    pub type Key = Txt;

    pub struct Category {
        pub id: Key,
        pub name: BoxedVar<Txt>,
        pub help: BoxedVar<Txt>,
    }
    impl PartialEq for Category {
        fn eq(&self, other: &Self) -> bool {
            self.id == other.id
        }
    }
    impl Eq for Category {}
    impl fmt::Debug for Category {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            f.debug_struct("Category")
                .field("id", &self.id)
                .finish_non_exhaustive()
        }
    }
}

```


Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6cb0cd195..99e6d6c4c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Unreleased
 
+* Add `zng::widget::ICONS`.
+* Add `zng::widget::WeakWidgetFn`.
+* Add `zng::widget::EDITORS`.
+* Add `dyn AnyConfig::get_raw_serde_bidi`.
+* Add `AnyVarValue::eq_any`.
+* Add `zng::config::settings`.
 
 # 0.9.1
 
diff --git a/crates/README.md b/crates/README.md
index 842fb33e5..00fd47d06 100644
--- a/crates/README.md
+++ b/crates/README.md
@@ -109,6 +109,7 @@ or for custom properties that deeply integrate with a widget.
 - `zng-wgt-toggle`
 - `zng-wgt-menu`
 - `zng-wgt-scroll`
+- `zng-wgt-settings`
 - `zng-wgt-ansi-text`
 - `zng-wgt-tooltip`
 - `zng-wgt-markdown`
diff --git a/crates/zng-ext-config/Cargo.toml b/crates/zng-ext-config/Cargo.toml
index 4bb01e359..c956b041b 100644
--- a/crates/zng-ext-config/Cargo.toml
+++ b/crates/zng-ext-config/Cargo.toml
@@ -30,6 +30,7 @@ zng-unique-id = { path = "../zng-unique-id", version = "0.4.4" }
 zng-ext-fs-watcher = { path = "../zng-ext-fs-watcher", version = "0.2.19", features = [
     "json",
 ] }
+zng-state-map = { path = "../zng-state-map", version = "0.3.3" }
 
 tracing = "0.1"
 bytemuck = { version = "1.15", features = ["derive"] }
diff --git a/crates/zng-ext-config/src/fallback.rs b/crates/zng-ext-config/src/fallback.rs
index ccae6d8f1..893a7215e 100644
--- a/crates/zng-ext-config/src/fallback.rs
+++ b/crates/zng-ext-config/src/fallback.rs
@@ -205,12 +205,15 @@ impl<S: Config, F: Config> AnyConfig for FallbackConfig<S, F> {
                     if is_reset {
                         fall_res_enabled.store(true, Ordering::Relaxed);
                     } else {
-                        fall_res_enabled.store(false, Ordering::Relaxed);
+                        let after_reset = fall_res_enabled.swap(false, Ordering::Relaxed);
                         let value = args.value().clone();
                         let _ = cfg_var.modify(move |v| {
                             if v.as_ref() != &value {
                                 v.set(value);
                                 v.push_tag(binding_tag);
+                            } else if after_reset {
+                                // cfg still has value from before reset, cause it to write
+                                v.update();
                             }
                         });
                     }
diff --git a/crates/zng-ext-config/src/lib.rs b/crates/zng-ext-config/src/lib.rs
index 1dce4b2ac..9afdabe15 100644
--- a/crates/zng-ext-config/src/lib.rs
+++ b/crates/zng-ext-config/src/lib.rs
@@ -25,11 +25,6 @@ pub use switch::*;
 
 mod sync;
 pub use sync::*;
-use zng_app_context::app_local;
-use zng_clone_move::clmv;
-use zng_ext_fs_watcher::{WatchFile, WatcherReadStatus, WatcherSyncStatus, WriteFile};
-use zng_txt::Txt;
-use zng_var::{types::WeakArcVar, var, AnyVar, AnyWeakVar, ArcVar, BoxedVar, LocalVar, Var, VarHandles, VarModify, VarValue, WeakVar};
 
 #[cfg(feature = "toml")]
 mod toml;
@@ -46,6 +41,8 @@ mod yaml;
 #[cfg(feature = "yaml")]
 pub use self::yaml::*;
 
+pub mod settings;
+
 use std::{
     any::Any,
     collections::{hash_map, HashMap},
@@ -54,7 +51,12 @@ use std::{
 };
 
 use zng_app::{update::EventUpdate, view_process::raw_events::LOW_MEMORY_EVENT, AppExtension};
+use zng_app_context::app_local;
+use zng_clone_move::clmv;
+use zng_ext_fs_watcher::{WatchFile, WatcherReadStatus, WatcherSyncStatus, WriteFile};
 use zng_task as task;
+use zng_txt::Txt;
+use zng_var::{types::WeakArcVar, var, AnyVar, AnyWeakVar, ArcVar, BoxedVar, LocalVar, Var, VarHandles, VarModify, VarValue, WeakVar};
 
 /// Application extension that provides mouse events and service.
 ///
@@ -255,11 +257,11 @@ pub trait AnyConfig: Send + Any {
     /// This method is used when `T` cannot be passed because the config is behind a dynamic reference,
     /// the backend must convert the value from the in memory representation to [`RawConfigValue`].
     ///
-    /// If `shared` is `true` and the key was already requested it the same var is returned, if `false`
+    /// If `shared` is `true` and the key was already requested the same var is returned, if `false`
     /// a new variable is always generated. Note that if you have two different variables for the same
     /// key they will go out-of-sync as updates from setting one variable do not propagate to the other.
     ///
-    /// The `default` value is used to if the key is not found in the config, the default value
+    /// The `default` value is used if the key is not found in the config, the default value
     /// is not inserted in the config, the key is inserted or replaced only when the returned variable updates.
     fn get_raw(&mut self, key: ConfigKey, default: RawConfigValue, shared: bool) -> BoxedVar<RawConfigValue>;
 
@@ -278,6 +280,56 @@ pub trait AnyConfig: Send + Any {
     /// Cleanup and flush RAM caches.
     fn low_memory(&mut self);
 }
+impl dyn AnyConfig {
+    /// Get raw config and setup a bidi binding that converts to and from `T`.
+    ///
+    /// See [`get_raw`](AnyConfig::get_raw) for more details about the inputs.
+    pub fn get_raw_serde_bidi<T: ConfigValue>(
+        &mut self,
+        key: impl Into<ConfigKey>,
+        default: impl FnOnce() -> T,
+        shared: bool,
+    ) -> BoxedVar<T> {
+        let key = key.into();
+        let default = default();
+        let source_var = self.get_raw(
+            key.clone(),
+            RawConfigValue::serialize(&default).unwrap_or_else(|e| panic!("invalid default value, {e}")),
+            shared,
+        );
+        let var = var(RawConfigValue::deserialize(source_var.get()).unwrap_or(default));
+
+        source_var
+            .bind_filter_map_bidi(
+                &var,
+                // Raw -> T
+                clmv!(key, |raw| {
+                    match RawConfigValue::deserialize(raw.clone()) {
+                        Ok(value) => Some(value),
+                        Err(e) => {
+                            tracing::error!("get_raw_serde_bidi({key:?}) error, {e:?}");
+                            None
+                        }
+                    }
+                }),
+                // T -> Raw
+                clmv!(key, source_var, |value| {
+                    let _strong_ref = &source_var;
+
+                    match RawConfigValue::serialize(value) {
+                        Ok(raw) => Some(raw),
+                        Err(e) => {
+                            tracing::error!("get_raw_serde_bidi({key:?}) error, {e:?}");
+                            None
+                        }
+                    }
+                }),
+            )
+            .perm();
+
+        var.boxed()
+    }
+}
 
 /// Represents one or more config sources.
 pub trait Config: AnyConfig {
diff --git a/crates/zng-ext-config/src/settings.rs b/crates/zng-ext-config/src/settings.rs
new file mode 100644
index 000000000..dc3ea892b
--- /dev/null
+++ b/crates/zng-ext-config/src/settings.rs
@@ -0,0 +1,783 @@
+//! Settings are the config the user can directly edit, this module implements a basic settings data model.
+//!
+//! The settings editor widget is not implemented here, this module bridges config implementers with settings UI implementers.
+
+use core::fmt;
+use std::{any::TypeId, cmp::Ordering, mem, ops, sync::Arc};
+
+use zng_app_context::app_local;
+use zng_state_map::{OwnedStateMap, StateId, StateMapMut, StateMapRef, StateValue};
+use zng_txt::Txt;
+use zng_var::{impl_from_and_into_var, var, AnyVar, AnyVarHookArgs, AnyVarValue, BoxedAnyVar, BoxedVar, IntoVar, LocalVar, Var};
+
+use crate::{Config, ConfigKey, ConfigValue, FallbackConfigReset, CONFIG};
+
+/// Settings metadata service.
+pub struct SETTINGS;
+
+impl SETTINGS {
+    /// Register a closure that provides settings metadata.
+    pub fn register(&self, f: impl Fn(&mut SettingsBuilder) + Send + Sync + 'static) {
+        SETTINGS_SV.write().sources.push(Box::new(f))
+    }
+
+    /// Register a closure that provides category metadata.
+    pub fn register_categories(&self, f: impl Fn(&mut CategoriesBuilder) + Send + Sync + 'static) {
+        SETTINGS_SV.write().sources_cat.push(Box::new(f))
+    }
+
+    /// Select and sort settings matched by `filter`.
+    pub fn get(&self, mut filter: impl FnMut(&ConfigKey, &CategoryId) -> bool, sort: bool) -> Vec<(Category, Vec<Setting>)> {
+        self.get_impl(&mut filter, sort)
+    }
+
+    fn get_impl(&self, filter: &mut dyn FnMut(&ConfigKey, &CategoryId) -> bool, sort: bool) -> Vec<(Category, Vec<Setting>)> {
+        let sv = SETTINGS_SV.read();
+
+        let mut settings = SettingsBuilder { settings: vec![], filter };
+        for source in sv.sources.iter() {
+            source(&mut settings);
+        }
+        let settings = settings.settings;
+
+        let mut categories = CategoriesBuilder {
+            categories: vec![],
+            filter: &mut |cat| settings.iter().any(|s| &s.category == cat),
+        };
+        for source in sv.sources_cat.iter() {
+            source(&mut categories);
+        }
+        let categories = categories.categories;
+
+        let mut result: Vec<_> = categories.into_iter().map(|c| (c, vec![])).collect();
+        for s in settings {
+            if let Some(i) = result.iter().position(|(c, _)| c.id == s.category) {
+                result[i].1.push(s);
+            } else {
+                tracing::warn!("missing category metadata for {}", s.category);
+                result.push((
+                    Category {
+                        id: s.category.clone(),
+                        order: u16::MAX,
+                        name: LocalVar(s.category.0.clone()).boxed(),
+                        meta: Arc::new(OwnedStateMap::new()),
+                    },
+                    vec![s],
+                ));
+            }
+        }
+
+        if sort {
+            self.sort(&mut result);
+        }
+        result
+    }
+
+    /// Gets if there are any setting matched by `filter`.
+    pub fn any(&self, mut filter: impl FnMut(&ConfigKey, &CategoryId) -> bool) -> bool {
+        self.any_impl(&mut filter)
+    }
+    fn any_impl(&self, filter: &mut dyn FnMut(&ConfigKey, &CategoryId) -> bool) -> bool {
+        let sv = SETTINGS_SV.read();
+
+        let mut any = false;
+
+        for source in sv.sources.iter() {
+            source(&mut SettingsBuilder {
+                settings: vec![],
+                filter: &mut |k, i| {
+                    if filter(k, i) {
+                        any = true;
+                    }
+                    false
+                },
+            });
+            if any {
+                break;
+            }
+        }
+
+        any
+    }
+
+    /// Count how many settings match the `filter`.
+    pub fn count(&self, mut filter: impl FnMut(&ConfigKey, &CategoryId) -> bool) -> usize {
+        self.count_impl(&mut filter)
+    }
+    fn count_impl(&self, filter: &mut dyn FnMut(&ConfigKey, &CategoryId) -> bool) -> usize {
+        let sv = SETTINGS_SV.read();
+
+        let mut count = 0;
+
+        for source in sv.sources.iter() {
+            source(&mut SettingsBuilder {
+                settings: vec![],
+                filter: &mut |k, i| {
+                    if filter(k, i) {
+                        count += 1;
+                    }
+                    false
+                },
+            });
+        }
+
+        count
+    }
+
+    /// Select and sort categories matched by `filter`.
+    ///
+    /// If `include_empty` is `true` includes categories that have no settings.
+    pub fn categories(&self, mut filter: impl FnMut(&CategoryId) -> bool, include_empty: bool, sort: bool) -> Vec<Category> {
+        self.categories_impl(&mut filter, include_empty, sort)
+    }
+    fn categories_impl(&self, filter: &mut dyn FnMut(&CategoryId) -> bool, include_empty: bool, sort: bool) -> Vec<Category> {
+        let sv = SETTINGS_SV.read();
+
+        let mut categories = CategoriesBuilder {
+            categories: vec![],
+            filter,
+        };
+        for source in sv.sources_cat.iter() {
+            source(&mut categories);
+        }
+        let mut result = categories.categories;
+
+        if !include_empty {
+            let mut non_empty = vec![];
+            for source in sv.sources.iter() {
+                source(&mut SettingsBuilder {
+                    settings: vec![],
+                    filter: &mut |_, cat| {
+                        if !non_empty.contains(cat) {
+                            non_empty.push(cat.clone());
+                        }
+                        false
+                    },
+                });
+            }
+
+            result.retain(|c| {
+                if let Some(i) = non_empty.iter().position(|id| &c.id == id) {
+                    non_empty.swap_remove(i);
+                    true
+                } else {
+                    false
+                }
+            });
+
+            for missing in non_empty {
+                tracing::warn!("missing category metadata for {}", missing);
+                result.push(Category::unknown(missing));
+            }
+        }
+
+        if sort {
+            self.sort_categories(&mut result)
+        }
+
+        result
+    }
+
+    /// Sort `settings`.
+    pub fn sort_settings(&self, settings: &mut [Setting]) {
+        settings.sort_by(|a, b| {
+            let c = a.order.cmp(&b.order);
+            if matches!(c, Ordering::Equal) {
+                return a.name.with(|a| b.name.with(|b| a.cmp(b)));
+            }
+            c
+        });
+    }
+
+    /// Sort `categories`.
+    pub fn sort_categories(&self, categories: &mut [Category]) {
+        categories.sort_by(|a, b| {
+            let c = a.order.cmp(&b.order);
+            if matches!(c, Ordering::Equal) {
+                return a.name.with(|a| b.name.with(|b| a.cmp(b)));
+            }
+            c
+        });
+    }
+
+    /// Sort categories and settings.
+    pub fn sort(&self, settings: &mut [(Category, Vec<Setting>)]) {
+        settings.sort_by(|a, b| {
+            let c = a.0.order.cmp(&b.0.order);
+            if matches!(c, Ordering::Equal) {
+                return a.0.name.with(|a| b.0.name.with(|b| a.cmp(b)));
+            }
+            c
+        });
+        for (_, s) in settings {
+            self.sort_settings(s);
+        }
+    }
+}
+
+/// Unique ID of a [`Category`].
+#[derive(PartialEq, Eq, Clone, Debug, Hash, Default)]
+pub struct CategoryId(pub Txt);
+impl_from_and_into_var! {
+    fn from(id: Txt) -> CategoryId {
+        CategoryId(id)
+    }
+    fn from(id: String) -> CategoryId {
+        CategoryId(id.into())
+    }
+    fn from(id: &'static str) -> CategoryId {
+        CategoryId(id.into())
+    }
+}
+impl ops::Deref for CategoryId {
+    type Target = Txt;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+impl fmt::Display for CategoryId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        fmt::Display::fmt(&self.0, f)
+    }
+}
+
+/// Settings category.
+#[derive(Clone)]
+pub struct Category {
+    id: CategoryId,
+    order: u16,
+    name: BoxedVar<Txt>,
+    meta: Arc<OwnedStateMap<Category>>,
+}
+impl Category {
+    /// Unique ID.
+    pub fn id(&self) -> &CategoryId {
+        &self.id
+    }
+
+    /// Position of the category in a list of categories.
+    ///
+    /// Lower numbers are listed first, two categories with the same order are sorted by display name.
+    pub fn order(&self) -> u16 {
+        self.order
+    }
+
+    /// Display name.
+    pub fn name(&self) -> &BoxedVar<Txt> {
+        &self.name
+    }
+
+    /// Custom category metadata.
+    pub fn meta(&self) -> StateMapRef<Category> {
+        self.meta.borrow()
+    }
+
+    /// Category from an ID only, no other metadata.
+    pub fn unknown(missing: CategoryId) -> Self {
+        Self {
+            id: missing.clone(),
+            order: u16::MAX,
+            name: LocalVar(missing.0).boxed(),
+            meta: Arc::default(),
+        }
+    }
+}
+impl PartialEq for Category {
+    fn eq(&self, other: &Self) -> bool {
+        self.id == other.id
+    }
+}
+impl Eq for Category {}
+impl fmt::Debug for Category {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("Category").field("id", &self.id).finish_non_exhaustive()
+    }
+}
+
+#[cfg(test)]
+fn _setting_in_var(s: Setting) {
+    let _x = LocalVar(s).get();
+}
+
+/// Setting entry.
+pub struct Setting {
+    key: ConfigKey,
+    order: u16,
+    name: BoxedVar<Txt>,
+    description: BoxedVar<Txt>,
+    category: CategoryId,
+    meta: Arc<OwnedStateMap<Setting>>,
+    value: BoxedAnyVar,
+    value_type: TypeId,
+    reset: Arc<dyn SettingReset>,
+}
+impl Clone for Setting {
+    fn clone(&self) -> Self {
+        Self {
+            key: self.key.clone(),
+            order: self.order,
+            name: self.name.clone(),
+            description: self.description.clone(),
+            category: self.category.clone(),
+            meta: self.meta.clone(),
+            value: self.value.clone(),
+            value_type: self.value_type,
+            reset: self.reset.clone(),
+        }
+    }
+}
+impl Setting {
+    /// The config edited by this setting.
+    pub fn key(&self) -> &ConfigKey {
+        &self.key
+    }
+
+    /// Position of the setting in a list of settings.
+    ///
+    /// Lower numbers are listed first, two settings with the same order are sorted by display name.
+    pub fn order(&self) -> u16 {
+        self.order
+    }
+
+    /// Display name.
+    pub fn name(&self) -> &BoxedVar<Txt> {
+        &self.name
+    }
+    /// Short help text.
+    pub fn description(&self) -> &BoxedVar<Txt> {
+        &self.description
+    }
+    /// Settings category.
+    pub fn category(&self) -> &CategoryId {
+        &self.category
+    }
+
+    /// Custom setting metadata.
+    pub fn meta(&self) -> StateMapRef<Setting> {
+        self.meta.borrow()
+    }
+
+    /// If the `value` is set to an actual config variable.
+    ///
+    /// Setting builders can not set the value, this can be used to indicate that the setting must be edited
+    /// directly on the config file.
+    pub fn value_is_set(&self) -> bool {
+        self.value_type != TypeId::of::<SettingValueNotSet>()
+    }
+
+    /// Config value.
+    pub fn value(&self) -> &BoxedAnyVar {
+        &self.value
+    }
+
+    /// Config value type.
+    pub fn value_type(&self) -> TypeId {
+        self.value_type
+    }
+
+    /// Config value, strongly typed.
+    pub fn value_downcast<T: ConfigValue>(&self) -> Option<BoxedVar<T>> {
+        if self.value_type == std::any::TypeId::of::<T>() {
+            let v = self.value.clone().double_boxed_any().downcast::<BoxedVar<T>>().unwrap();
+            Some(*v)
+        } else {
+            None
+        }
+    }
+
+    /// Gets a variable that indicates the current setting value is not the default.
+    pub fn can_reset(&self) -> BoxedVar<bool> {
+        self.reset.can_reset(&self.key, &self.value)
+    }
+
+    /// Reset the setting value.
+    pub fn reset(&self) {
+        self.reset.reset(&self.key, &self.value);
+    }
+
+    /// Gets if the setting should be included in the search and how likely it is to be an exact match (0 is exact).
+    ///
+    /// If `search` starts with `@key:` matches key case sensitive, otherwise matches name or description in lower case. Note
+    /// that non-key search is expected to already be lowercase.
+    pub fn search_index(&self, search: &str) -> Option<usize> {
+        if let Some(key) = search.strip_prefix("@key:") {
+            return if self.key.contains(key) {
+                Some(self.key.len() - search.len())
+            } else {
+                None
+            };
+        }
+
+        let r = self.name.with(|s| {
+            let s = s.to_lowercase();
+            if s.contains(search) {
+                Some(s.len() - search.len())
+            } else {
+                None
+            }
+        });
+        if r.is_some() {
+            return r;
+        }
+
+        self.description.with(|s| {
+            let s = s.to_lowercase();
+            if s.contains(search) {
+                Some(s.len() - search.len() + usize::MAX - 2)
+            } else {
+                None
+            }
+        })
+    }
+}
+impl PartialEq for Setting {
+    fn eq(&self, other: &Self) -> bool {
+        self.key == other.key
+    }
+}
+impl Eq for Setting {}
+impl fmt::Debug for Setting {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("Setting").field("key", &self.key).finish_non_exhaustive()
+    }
+}
+
+app_local! {
+    static SETTINGS_SV: SettingsService = SettingsService {
+        sources: vec![],
+        sources_cat: vec![],
+    };
+}
+struct SettingsService {
+    sources: Vec<Box<dyn Fn(&mut SettingsBuilder) + Send + Sync + 'static>>,
+    sources_cat: Vec<Box<dyn Fn(&mut CategoriesBuilder) + Send + Sync + 'static>>,
+}
+
+/// Settings builder.
+pub struct SettingsBuilder<'a> {
+    settings: Vec<Setting>,
+    filter: &'a mut dyn FnMut(&ConfigKey, &CategoryId) -> bool,
+}
+impl<'c> SettingsBuilder<'c> {
+    /// Calls `builder` for the key and category if it is not filtered by the view query.
+    ///
+    /// If the setting is already present the builder overrides only the metadata set.
+    pub fn entry(
+        &mut self,
+        config_key: impl Into<ConfigKey>,
+        category_id: impl Into<CategoryId>,
+        builder: impl for<'a, 'b> FnOnce(&'a mut SettingBuilder<'b>) -> &'a mut SettingBuilder<'b>,
+    ) -> &mut Self {
+        if let Some(mut e) = self.entry_impl(config_key.into(), category_id.into()) {
+            builder(&mut e);
+        }
+        self
+    }
+    fn entry_impl(&mut self, config_key: ConfigKey, category_id: CategoryId) -> Option<SettingBuilder> {
+        if (self.filter)(&config_key, &category_id) {
+            if let Some(i) = self.settings.iter().position(|s| s.key == config_key) {
+                let existing = self.settings.swap_remove(i);
+                Some(SettingBuilder {
+                    settings: &mut self.settings,
+                    config_key,
+                    category_id,
+                    order: existing.order,
+                    name: Some(existing.name),
+                    description: Some(existing.description),
+                    meta: Arc::try_unwrap(existing.meta).unwrap(),
+                    value: None,
+                    reset: None,
+                })
+            } else {
+                Some(SettingBuilder {
+                    settings: &mut self.settings,
+                    config_key,
+                    category_id,
+                    order: u16::MAX,
+                    name: None,
+                    description: None,
+                    meta: OwnedStateMap::new(),
+                    value: None,
+                    reset: None,
+                })
+            }
+        } else {
+            None
+        }
+    }
+}
+
+/// Setting entry builder.
+pub struct SettingBuilder<'a> {
+    settings: &'a mut Vec<Setting>,
+    config_key: ConfigKey,
+    category_id: CategoryId,
+    order: u16,
+    name: Option<BoxedVar<Txt>>,
+    description: Option<BoxedVar<Txt>>,
+    meta: OwnedStateMap<Setting>,
+    value: Option<(BoxedAnyVar, TypeId)>,
+    reset: Option<Arc<dyn SettingReset>>,
+}
+impl<'a> SettingBuilder<'a> {
+    /// The config edited by this setting.
+    pub fn key(&self) -> &ConfigKey {
+        &self.config_key
+    }
+    /// Settings category.
+    pub fn category(&self) -> &CategoryId {
+        &self.category_id
+    }
+
+    /// Set the setting order number.
+    ///
+    /// Lower numbers are listed first, two categories with the same order are sorted by display name.
+    pub fn order(&mut self, order: u16) -> &mut Self {
+        self.order = order;
+        self
+    }
+
+    /// Set the setting name.
+    pub fn name(&mut self, name: impl IntoVar<Txt>) -> &mut Self {
+        self.name = Some(name.into_var().read_only().boxed());
+        self
+    }
+
+    /// Set the setting short help text.
+    pub fn description(&mut self, description: impl IntoVar<Txt>) -> &mut Self {
+        self.description = Some(description.into_var().read_only().boxed());
+        self
+    }
+
+    /// Set the custom metadata value.
+    pub fn set<T: StateValue>(&mut self, id: impl Into<StateId<T>>, value: impl Into<T>) -> &mut Self {
+        self.meta.borrow_mut().set(id, value);
+        self
+    }
+
+    /// Set the custom metadata flag.
+    pub fn flag(&mut self, id: impl Into<StateId<()>>) -> &mut Self {
+        self.meta.borrow_mut().flag(id);
+        self
+    }
+
+    /// Custom setting metadata.
+    pub fn meta(&mut self) -> StateMapMut<Setting> {
+        self.meta.borrow_mut()
+    }
+
+    /// Set the value variable from [`CONFIG`].
+    pub fn value<T: ConfigValue>(&mut self, default: impl FnOnce() -> T) -> &mut Self {
+        self.cfg_value(&mut CONFIG, default)
+    }
+
+    /// Set the value variable from a different config.
+    pub fn cfg_value<T: ConfigValue>(&mut self, cfg: &mut impl Config, default: impl FnOnce() -> T) -> &mut Self {
+        let value = cfg.get(self.config_key.clone(), default);
+        self.value = Some((value.boxed_any(), TypeId::of::<T>()));
+        self
+    }
+
+    /// Use a [`FallbackConfigReset`] to reset the settings.
+    ///
+    /// This is the preferred way of implementing reset as it keeps the user config file clean,
+    /// but it does require a config setup with two files.
+    ///
+    /// The `strip_key_prefix` is removed from config keys before passing to `resetter`, this is
+    /// required if the config is setup using a switch over multiple files.
+    pub fn reset(&mut self, resetter: Box<dyn FallbackConfigReset>, strip_key_prefix: impl Into<Txt>) -> &mut Self {
+        self.reset = Some(Arc::new(FallbackReset {
+            resetter,
+            strip_key_prefix: strip_key_prefix.into(),
+        }));
+        self
+    }
+
+    /// Use a `default` value to reset the settings.
+    ///
+    /// The default value is set on the config to reset.
+    pub fn default<T: ConfigValue>(&mut self, default: T) -> &mut Self {
+        let reset: Box<dyn AnyVarValue> = Box::new(default);
+        self.reset = Some(Arc::new(reset));
+        self
+    }
+}
+impl<'a> Drop for SettingBuilder<'a> {
+    fn drop(&mut self) {
+        let (cfg, cfg_type) = self
+            .value
+            .take()
+            .unwrap_or_else(|| (LocalVar(SettingValueNotSet).boxed_any(), TypeId::of::<SettingValueNotSet>()));
+        self.settings.push(Setting {
+            key: mem::take(&mut self.config_key),
+            order: self.order,
+            name: self.name.take().unwrap_or_else(|| var(Txt::from_static("")).boxed()),
+            description: self.description.take().unwrap_or_else(|| var(Txt::from_static("")).boxed()),
+            category: mem::take(&mut self.category_id),
+            meta: Arc::new(mem::take(&mut self.meta)),
+            value: cfg,
+            value_type: cfg_type,
+            reset: self.reset.take().unwrap_or_else(|| Arc::new(SettingValueNotSet)),
+        })
+    }
+}
+
+#[derive(Clone, PartialEq, Debug)]
+struct SettingValueNotSet;
+
+/// Setting categories builder.
+pub struct CategoriesBuilder<'f> {
+    categories: Vec<Category>,
+    filter: &'f mut dyn FnMut(&CategoryId) -> bool,
+}
+impl<'f> CategoriesBuilder<'f> {
+    /// Calls `builder` for the id if it is not filtered by the view query.
+    ///
+    /// If the category is already present the builder overrides only the metadata set.
+    pub fn entry(
+        &mut self,
+        category_id: impl Into<CategoryId>,
+        builder: impl for<'a, 'b> FnOnce(&'a mut CategoryBuilder<'b>) -> &'a mut CategoryBuilder<'b>,
+    ) -> &mut Self {
+        if let Some(mut e) = self.entry_impl(category_id.into()) {
+            builder(&mut e);
+        }
+        self
+    }
+    fn entry_impl(&mut self, category_id: CategoryId) -> Option<CategoryBuilder> {
+        if (self.filter)(&category_id) {
+            if let Some(i) = self.categories.iter().position(|s| s.id == category_id) {
+                let existing = self.categories.swap_remove(i);
+                Some(CategoryBuilder {
+                    categories: &mut self.categories,
+                    category_id,
+                    order: existing.order,
+                    name: Some(existing.name),
+                    meta: Arc::try_unwrap(existing.meta).unwrap(),
+                })
+            } else {
+                Some(CategoryBuilder {
+                    categories: &mut self.categories,
+                    category_id,
+                    order: u16::MAX,
+                    name: None,
+                    meta: OwnedStateMap::new(),
+                })
+            }
+        } else {
+            None
+        }
+    }
+}
+
+/// Category entry builder.
+pub struct CategoryBuilder<'a> {
+    categories: &'a mut Vec<Category>,
+    category_id: CategoryId,
+    order: u16,
+    name: Option<BoxedVar<Txt>>,
+    meta: OwnedStateMap<Category>,
+}
+impl<'a> CategoryBuilder<'a> {
+    /// Unique ID.
+    pub fn id(&self) -> &CategoryId {
+        &self.category_id
+    }
+
+    /// Set the position of the category in a list of categories.
+    ///
+    /// Lower numbers are listed first, two categories with the same order are sorted by display name.
+    pub fn order(&mut self, order: u16) -> &mut Self {
+        self.order = order;
+        self
+    }
+
+    /// Set the category name.
+    pub fn name(&mut self, name: impl IntoVar<Txt>) -> &mut Self {
+        self.name = Some(name.into_var().read_only().boxed());
+        self
+    }
+
+    /// Set the custom metadata value.
+    pub fn set<T: StateValue>(&mut self, id: impl Into<StateId<T>>, value: impl Into<T>) -> &mut Self {
+        self.meta.borrow_mut().set(id, value);
+        self
+    }
+
+    /// Set the custom metadata flag.
+    pub fn flag(&mut self, id: impl Into<StateId<()>>) -> &mut Self {
+        self.meta.borrow_mut().flag(id);
+        self
+    }
+
+    /// Custom category metadata.
+    pub fn meta(&mut self) -> StateMapMut<Category> {
+        self.meta.borrow_mut()
+    }
+}
+impl<'a> Drop for CategoryBuilder<'a> {
+    fn drop(&mut self) {
+        self.categories.push(Category {
+            id: mem::take(&mut self.category_id),
+            order: self.order,
+            name: self.name.take().unwrap_or_else(|| var(Txt::from_static("")).boxed()),
+            meta: Arc::new(mem::take(&mut self.meta)),
+        })
+    }
+}
+trait SettingReset: Send + Sync + 'static {
+    fn can_reset(&self, key: &ConfigKey, value: &BoxedAnyVar) -> BoxedVar<bool>;
+    fn reset(&self, key: &ConfigKey, value: &BoxedAnyVar);
+}
+
+struct FallbackReset {
+    resetter: Box<dyn FallbackConfigReset>,
+    strip_key_prefix: Txt,
+}
+
+impl SettingReset for FallbackReset {
+    fn can_reset(&self, key: &ConfigKey, _: &BoxedAnyVar) -> BoxedVar<bool> {
+        match key.strip_prefix(self.strip_key_prefix.as_str()) {
+            Some(k) => self.resetter.can_reset(ConfigKey::from_str(k)),
+            None => self.resetter.can_reset(key.clone()),
+        }
+    }
+
+    fn reset(&self, key: &ConfigKey, _: &BoxedAnyVar) {
+        match key.strip_prefix(self.strip_key_prefix.as_str()) {
+            Some(k) => self.resetter.reset(&ConfigKey::from_str(k)),
+            None => self.resetter.reset(key),
+        }
+    }
+}
+impl SettingReset for Box<dyn AnyVarValue> {
+    fn can_reset(&self, _: &ConfigKey, value: &BoxedAnyVar) -> BoxedVar<bool> {
+        let mut initial = false;
+        value.with_any(&mut |v| {
+            initial = v.eq_any(&**self);
+        });
+        let map = var(initial);
+
+        let map_in = map.clone();
+        let dft = self.clone_boxed();
+        value
+            .hook_any(Box::new(move |args: &AnyVarHookArgs| {
+                map_in.set(args.value().eq_any(&*dft));
+                true
+            }))
+            .perm();
+
+        map.clone().boxed()
+    }
+
+    fn reset(&self, _: &ConfigKey, value: &BoxedAnyVar) {
+        let _ = value.set_any(self.clone_boxed());
+    }
+}
+impl SettingReset for SettingValueNotSet {
+    fn can_reset(&self, _: &ConfigKey, _: &BoxedAnyVar) -> BoxedVar<bool> {
+        LocalVar(false).boxed()
+    }
+    fn reset(&self, _: &ConfigKey, _: &BoxedAnyVar) {}
+}
diff --git a/crates/zng-ext-config/src/swap.rs b/crates/zng-ext-config/src/swap.rs
index b1ad3394e..8d75ba899 100644
--- a/crates/zng-ext-config/src/swap.rs
+++ b/crates/zng-ext-config/src/swap.rs
@@ -1,7 +1,6 @@
 use super::*;
 
 use crate::task::parking_lot::Mutex;
-use zng_clone_move::clmv;
 use zng_var::VarHandle;
 
 /// Represents a config source that can swap its backing config source without disconnecting any bound keys.
@@ -47,44 +46,7 @@ impl Config for SwapConfig {
     fn get<T: ConfigValue>(&mut self, key: impl Into<ConfigKey>, default: impl FnOnce() -> T) -> BoxedVar<T> {
         self.shared.get_or_bind(key.into(), |key| {
             // not in shared, bind with source json var.
-
-            let default = default();
-            let source_var = self.cfg.get_mut().get_raw(
-                key.clone(),
-                RawConfigValue::serialize(&default).unwrap_or_else(|e| panic!("invalid default value, {e}")),
-                false,
-            );
-            let var = var(RawConfigValue::deserialize(source_var.get()).unwrap_or(default));
-
-            source_var
-                .bind_filter_map_bidi(
-                    &var,
-                    // Raw -> T
-                    clmv!(key, |raw| {
-                        match RawConfigValue::deserialize(raw.clone()) {
-                            Ok(value) => Some(value),
-                            Err(e) => {
-                                tracing::error!("swap config get({key:?}) error, {e:?}");
-                                None
-                            }
-                        }
-                    }),
-                    // T -> Raw
-                    clmv!(key, source_var, |value| {
-                        let _strong_ref = &source_var;
-
-                        match RawConfigValue::serialize(value) {
-                            Ok(raw) => Some(raw),
-                            Err(e) => {
-                                tracing::error!("swap config set({key:?}) error, {e:?}");
-                                None
-                            }
-                        }
-                    }),
-                )
-                .perm();
-
-            var.boxed()
+            self.cfg.get_mut().get_raw_serde_bidi(key.clone(), default, false)
         })
     }
 }
diff --git a/crates/zng-ext-config/src/sync.rs b/crates/zng-ext-config/src/sync.rs
index c4eaaa0d2..91b1a93cd 100644
--- a/crates/zng-ext-config/src/sync.rs
+++ b/crates/zng-ext-config/src/sync.rs
@@ -1,4 +1,4 @@
-use std::path::PathBuf;
+use std::{path::PathBuf, sync::atomic::AtomicBool};
 
 use atomic::{Atomic, Ordering};
 use zng_clone_move::clmv;
@@ -76,6 +76,7 @@ impl<M: ConfigMap> SyncConfig<M> {
         // config -> entry
         let wk_var = var.downgrade();
         let last_update = Atomic::new(VarUpdateId::never());
+        let backend_lost_entry = AtomicBool::new(false);
         sync_var
             .hook(clmv!(key, |map| {
                 let update_id = VARS.update_id();
@@ -89,8 +90,15 @@ impl<M: ConfigMap> SyncConfig<M> {
                             // get ok
                             if let Some(raw) = raw {
                                 var.set(raw);
+                                if backend_lost_entry.swap(false, Ordering::Relaxed) {
+                                    // restored after reset, var can already have the value,
+                                    // but upstream bindings are stale, cause an update.
+                                    var.update();
+                                }
+                            } else {
+                                // backend lost entry but did not report as error, probably a reset.
+                                backend_lost_entry.store(true, Ordering::Relaxed);
                             }
-                            // else backend lost entry but did not report as error.
                         }
                         Err(e) => {
                             // get error
diff --git a/crates/zng-ext-input/src/focus/focus_info.rs b/crates/zng-ext-input/src/focus/focus_info.rs
index ad4cd6ba8..896544ea1 100644
--- a/crates/zng-ext-input/src/focus/focus_info.rs
+++ b/crates/zng-ext-input/src/focus/focus_info.rs
@@ -36,6 +36,9 @@ impl TabIndex {
     /// The integer value is `u32::MAX / 2`.
     pub const AUTO: TabIndex = TabIndex(u32::MAX / 2);
 
+    /// Last possible widget index.
+    pub const LAST: TabIndex = TabIndex(u32::MAX - 1);
+
     /// If is [`SKIP`](TabIndex::SKIP).
     pub fn is_skip(self) -> bool {
         self == Self::SKIP
diff --git a/crates/zng-var/src/lib.rs b/crates/zng-var/src/lib.rs
index 973dc6226..d0c0c080c 100644
--- a/crates/zng-var/src/lib.rs
+++ b/crates/zng-var/src/lib.rs
@@ -176,6 +176,9 @@ pub trait AnyVarValue: fmt::Debug + Any + Send + Sync {
 
     /// Clone the value into a new boxed [`LocalVar<Self>`].
     fn clone_boxed_var(&self) -> BoxedAnyVar;
+
+    /// Gets if `self` equals `other`.
+    fn eq_any(&self, other: &dyn AnyVarValue) -> bool;
 }
 
 impl<T: VarValue> AnyVarValue for T {
@@ -198,6 +201,13 @@ impl<T: VarValue> AnyVarValue for T {
     fn into_any(self: Box<Self>) -> Box<dyn Any> {
         self
     }
+
+    fn eq_any(&self, other: &dyn AnyVarValue) -> bool {
+        match other.as_any().downcast_ref::<T>() {
+            Some(v) => self == v,
+            None => false,
+        }
+    }
 }
 
 /// A property value that is not a variable but can be inspected.
diff --git a/crates/zng-wgt-input/src/cmd.rs b/crates/zng-wgt-input/src/cmd.rs
index b59a1e615..84bca9ffa 100644
--- a/crates/zng-wgt-input/src/cmd.rs
+++ b/crates/zng-wgt-input/src/cmd.rs
@@ -44,6 +44,19 @@ command! {
     pub static CONTEXT_MENU_CMD = {
         shortcut: [shortcut!(SHIFT+F10), shortcut!(ContextMenu)],
     };
+
+    /// Represents the **open settings** action.
+    ///
+    /// Settings is an editor for a selection of app configs.
+    ///
+    /// # Parameter
+    ///
+    /// The parameter can be a `Txt` that can match a `ConfigKey` or config metadata
+    /// such as the display name or description.
+    pub static SETTINGS_CMD = {
+        name: "Settings",
+        shortcut: [shortcut!(CTRL+',')],
+    };
 }
 
 command_property! {
@@ -121,4 +134,11 @@ command_property! {
     pub fn paste {
         cmd: PASTE_CMD.scoped(WIDGET.id()),
     }
+
+    /// On settings command.
+    ///
+    /// Receives [`SETTINGS_CMD`] command events scoped on the widget.
+    pub fn settings {
+        cmd: SETTINGS_CMD.scoped(WIDGET.id()),
+    }
 }
diff --git a/crates/zng-wgt-settings/Cargo.toml b/crates/zng-wgt-settings/Cargo.toml
new file mode 100644
index 000000000..e96e3d29c
--- /dev/null
+++ b/crates/zng-wgt-settings/Cargo.toml
@@ -0,0 +1,35 @@
+[package]
+name = "zng-wgt-settings"
+version = "0.1.0"
+authors = ["The Zng Project Developers"]
+edition = "2021"
+license = "Apache-2.0 OR MIT"
+readme = "README.md"
+description = "Part of the zng project."
+documentation = "https://zng-ui.github.io/doc/zng_wgt_access"
+repository = "https://github.com/zng-ui/zng"
+categories = ["gui"]
+keywords = ["gui", "ui", "user-interface", "zng"]
+
+[dependencies]
+zng-app = { path = "../zng-app", version = "0.8.0" }
+zng-wgt = { path = "../zng-wgt", version = "0.4.2" }
+zng-var = { path = "../zng-var", version = "0.4.5" }
+zng-ext-config = { path = "../zng-ext-config", version = "0.2.19" }
+zng-ext-font = { path = "../zng-ext-font", version = "0.3.15" }
+zng-wgt-text = { path = "../zng-wgt-text", version = "0.3.9" }
+zng-wgt-stack = { path = "../zng-wgt-stack", version = "0.3.9" }
+zng-wgt-toggle = { path = "../zng-wgt-toggle", version = "0.3.9" }
+zng-wgt-container = { path = "../zng-wgt-container", version = "0.2.19" }
+zng-wgt-scroll = { path = "../zng-wgt-scroll", version = "0.4.9" }
+zng-wgt-text-input = { path = "../zng-wgt-text-input", version = "0.3.9" }
+zng-wgt-button = { path = "../zng-wgt-button", version = "0.3.9" }
+zng-wgt-rule-line = { path = "../zng-wgt-rule-line", version = "0.3.9" }
+zng-wgt-style = { path = "../zng-wgt-style", version = "0.3.9" }
+zng-wgt-filter = { path = "../zng-wgt-filter", version = "0.2.19" }
+zng-wgt-input = { path = "../zng-wgt-input", version = "0.2.19" }
+zng-wgt-size-offset = { path = "../zng-wgt-size-offset", version = "0.2.19" }
+zng-wgt-fill = { path = "../zng-wgt-fill", version = "0.2.19" }
+zng-wgt-markdown = { path = "../zng-wgt-markdown", version = "0.3.9" }
+zng-wgt-tooltip = { path = "../zng-wgt-tooltip", version = "0.3.9" }
+zng-ext-input = { path = "../zng-ext-input", version = "0.5.7" }
diff --git a/crates/zng-wgt-settings/README.md b/crates/zng-wgt-settings/README.md
new file mode 100644
index 000000000..bf784cf50
--- /dev/null
+++ b/crates/zng-wgt-settings/README.md
@@ -0,0 +1,7 @@
+<!--do doc --readme header-->
+This crate is part of the [`zng`](https://github.com/zng-ui/zng?tab=readme-ov-file#crates) project.
+
+
+<!--do doc --readme features-->
+
+
diff --git a/crates/zng-wgt-settings/src/lib.rs b/crates/zng-wgt-settings/src/lib.rs
new file mode 100644
index 000000000..0d882dbfd
--- /dev/null
+++ b/crates/zng-wgt-settings/src/lib.rs
@@ -0,0 +1,226 @@
+#![doc(html_favicon_url = "https://raw.githubusercontent.com/zng-ui/zng/main/examples/image/res/zng-logo-icon.png")]
+#![doc(html_logo_url = "https://raw.githubusercontent.com/zng-ui/zng/main/examples/image/res/zng-logo.png")]
+//! Settings widgets.
+
+zng_wgt::enable_widget_macros!();
+
+mod view_fn;
+pub use view_fn::*;
+
+use zng_ext_config::{
+    settings::{Category, CategoryId, Setting, SETTINGS},
+    ConfigKey,
+};
+use zng_wgt::prelude::*;
+use zng_wgt_container::Container;
+
+/// Settings editor widget.
+///
+/// The editor
+#[widget($crate::SettingsEditor)]
+pub struct SettingsEditor(WidgetBase);
+impl SettingsEditor {
+    fn widget_intrinsic(&mut self) {
+        self.widget_builder().push_build_action(|wgt| {
+            let child = settings_editor_node();
+            wgt.set_child(child.boxed());
+        });
+    }
+}
+
+/// Implements the [`SettingsEditor!`] inner widgets.
+///
+/// [`SettingsEditor!`]: struct@SettingsEditor
+pub fn settings_editor_node() -> impl UiNode {
+    let search = var(Txt::from_static(""));
+    let selected_cat = var(CategoryId::from(""));
+    match_node(NilUiNode.boxed(), move |c, op| match op {
+        UiNodeOp::Init => {
+            WIDGET
+                .sub_var(&SETTINGS_FN_VAR)
+                .sub_var(&SETTING_FN_VAR)
+                .sub_var(&SETTINGS_SEARCH_FN_VAR)
+                .sub_var(&CATEGORIES_LIST_FN_VAR)
+                .sub_var(&CATEGORY_HEADER_FN_VAR)
+                .sub_var(&CATEGORY_ITEM_FN_VAR);
+            *c.child() = settings_view_fn(search.clone(), selected_cat.clone()).boxed();
+        }
+        UiNodeOp::Deinit => {
+            c.deinit();
+            *c.child() = NilUiNode.boxed();
+            search.set("");
+            selected_cat.set("");
+        }
+        UiNodeOp::Update { .. } => {
+            if SETTINGS_FN_VAR.is_new()
+                || SETTING_FN_VAR.is_new()
+                || SETTINGS_SEARCH_FN_VAR.is_new()
+                || CATEGORIES_LIST_FN_VAR.is_new()
+                || CATEGORY_HEADER_FN_VAR.is_new()
+                || CATEGORY_ITEM_FN_VAR.is_new()
+            {
+                c.delegated();
+                c.child().deinit();
+                *c.child() = settings_view_fn(search.clone(), selected_cat.clone()).boxed();
+                c.child().init();
+                WIDGET.update_info().layout().render();
+            }
+        }
+        _ => {}
+    })
+}
+
+fn settings_view_fn(search: ArcVar<Txt>, selected_cat: ArcVar<CategoryId>) -> impl UiNode {
+    let search_box = SETTINGS_SEARCH_FN_VAR.get()(SettingsSearchArgs { search: search.clone() });
+
+    // avoids rebuilds for ignored search changes
+    let clean_search = search.map(|s| {
+        let s = s.trim();
+        if !s.starts_with('@') {
+            s.to_lowercase().into()
+        } else {
+            Txt::from_str(s)
+        }
+    });
+
+    // live query
+    #[derive(PartialEq, Debug, Clone)]
+    struct Results {
+        categories: Vec<Category>,
+        selected_cat: Category,
+        selected_settings: Vec<Setting>,
+        top_match: ConfigKey,
+    }
+    let sel_cat = selected_cat.clone();
+    let search_results = expr_var! {
+        if #{clean_search}.is_empty() {
+            // no search, does not need to load settings of other categories
+            let (cat, settings) = SETTINGS.get(|_, cat| cat == #{sel_cat}, true)
+                            .pop().unwrap_or_else(|| (Category::unknown(#{sel_cat}.clone()), vec![]));
+            Results {
+                categories: SETTINGS.categories(|_| true, false, true),
+                selected_cat: cat,
+                top_match: settings.first().map(|s| s.key().clone()).unwrap_or_default(),
+                selected_settings: settings,
+            }
+        } else {
+            // has search, just load everything
+            let mut r = SETTINGS.get(|_, _| true, false);
+
+            // apply search filter, get best match key (top_match), actual selected_cat.
+            let mut top_match = (usize::MAX, Txt::from(""));
+            let mut actual_cat = None;
+            r.retain_mut(|(c, s)| if c.id() == #{sel_cat} {
+                // is selected cat
+                actual_cat = Some(c.clone());
+                // actually filter settings
+                s.retain(|s| match s.search_index(#{clean_search}) {
+                    Some(i) => {
+                        if i < top_match.0 {
+                            top_match = (i, s.key().clone());
+                        }
+                        true
+                    }
+                    None => false,
+                });
+                !s.is_empty()
+            } else {
+                // is not selected cat, just search, settings will be ignored
+                s.iter().any(|s| match s.search_index(#{clean_search}) {
+                    Some(i) => {
+                        if i < top_match.0 {
+                            top_match = (i, s.key().clone());
+                        }
+                        true
+                    }
+                    None => false,
+                })
+            });
+            let mut r = Results {
+                categories: r.iter().map(|(c, _)| c.clone()).collect(),
+                selected_cat: actual_cat.unwrap_or_else(|| Category::unknown(#{sel_cat}.clone())),
+                selected_settings: r.into_iter().find_map(|(c, s)| if c.id() == #{sel_cat} { Some(s) } else { None }).unwrap_or_default(),
+                top_match: top_match.1,
+            };
+            SETTINGS.sort_categories(&mut r.categories);
+            SETTINGS.sort_settings(&mut r.selected_settings);
+            r
+        }
+    };
+
+    // select first category when previous selection is removed
+    let wk_sel_cat = selected_cat.downgrade();
+    fn correct_sel(options: &[Category], sel: &ArcVar<CategoryId>) {
+        if sel.with(|s| !options.iter().any(|c| c.id() == s)) {
+            if let Some(first) = options.first() {
+                sel.set(first.id().clone());
+            }
+        }
+    }
+    search_results
+        .hook(move |r| {
+            if let Some(sel) = wk_sel_cat.upgrade() {
+                correct_sel(&r.value().categories, &sel);
+                true
+            } else {
+                false
+            }
+        })
+        .perm();
+    search_results.with(|r| {
+        correct_sel(&r.categories, &selected_cat);
+    });
+
+    let categories = presenter(
+        search_results.map_ref(|r| &r.categories),
+        wgt_fn!(|categories: Vec<Category>| {
+            let cat_fn = CATEGORY_ITEM_FN_VAR.get();
+            let categories: UiNodeVec = categories
+                .into_iter()
+                .enumerate()
+                .map(|(i, c)| cat_fn(CategoryItemArgs { index: i, category: c }))
+                .collect();
+
+            CATEGORIES_LIST_FN_VAR.get()(CategoriesListArgs {
+                items: categories,
+                selected: selected_cat.clone(),
+            })
+        }),
+    );
+
+    let settings = presenter(
+        search_results,
+        wgt_fn!(|Results {
+                     selected_cat,
+                     selected_settings,
+                     ..
+                 }: Results| {
+            let setting_fn = SETTING_FN_VAR.get();
+
+            let settings: UiNodeVec = selected_settings
+                .into_iter()
+                .enumerate()
+                .map(|(i, s)| {
+                    let editor = s.editor();
+                    setting_fn(SettingArgs {
+                        index: i,
+                        setting: s.clone(),
+                        editor,
+                    })
+                })
+                .collect();
+
+            let header = CATEGORY_HEADER_FN_VAR.get()(CategoryHeaderArgs { category: selected_cat });
+
+            SETTINGS_FN_VAR.get()(SettingsArgs { header, items: settings })
+        }),
+    );
+
+    Container! {
+        child_top = search_box, 0;
+        child = Container! {
+            child_start = categories, 0;
+            child = settings
+        };
+    }
+}
diff --git a/crates/zng-wgt-settings/src/view_fn.rs b/crates/zng-wgt-settings/src/view_fn.rs
new file mode 100644
index 000000000..47df67a17
--- /dev/null
+++ b/crates/zng-wgt-settings/src/view_fn.rs
@@ -0,0 +1,364 @@
+use zng_app::{
+    static_id,
+    widget::{
+        node::{BoxedUiNode, UiNode, UiNodeVec},
+        property,
+    },
+};
+use zng_ext_config::{
+    settings::{Category, CategoryId, Setting, SettingBuilder, SETTINGS},
+    ConfigKey,
+};
+use zng_ext_font::FontWeight;
+use zng_var::{ContextInitHandle, ReadOnlyContextVar};
+use zng_wgt::{node::with_context_var, prelude::*, Wgt, WidgetFn, EDITORS, ICONS};
+use zng_wgt_container::Container;
+use zng_wgt_filter::opacity;
+use zng_wgt_markdown::Markdown;
+use zng_wgt_rule_line::{hr::Hr, vr::Vr};
+use zng_wgt_scroll::{Scroll, ScrollMode};
+use zng_wgt_stack::{Stack, StackDirection};
+use zng_wgt_style::Style;
+use zng_wgt_text::Text;
+use zng_wgt_text_input::TextInput;
+use zng_wgt_toggle::{Selector, Toggle};
+use zng_wgt_tooltip::{disabled_tooltip, tooltip, Tip};
+
+use crate::SettingsEditor;
+
+context_var! {
+    /// Category in a category list.
+    pub static CATEGORY_ITEM_FN_VAR: WidgetFn<CategoryItemArgs> = WidgetFn::new(default_category_item_fn);
+    /// Categories list.
+    pub static CATEGORIES_LIST_FN_VAR: WidgetFn<CategoriesListArgs> = WidgetFn::new(default_categories_list_fn);
+    /// Category header on the settings list.
+    pub static CATEGORY_HEADER_FN_VAR: WidgetFn<CategoryHeaderArgs> = WidgetFn::new(default_category_header_fn);
+    /// Setting item.
+    pub static SETTING_FN_VAR: WidgetFn<SettingArgs> = WidgetFn::new(default_setting_fn);
+    /// Settings list for a category.
+    pub static SETTINGS_FN_VAR: WidgetFn<SettingsArgs> = WidgetFn::new(default_settings_fn);
+    /// Settings search area.
+    pub static SETTINGS_SEARCH_FN_VAR: WidgetFn<SettingsSearchArgs> = WidgetFn::new(default_settings_search_fn);
+}
+
+/// Widget function that converts [`CategoryItemArgs`] to a category item on a category list.
+///
+/// Sets the [`CATEGORY_ITEM_FN_VAR`].
+#[property(CONTEXT, default(CATEGORY_ITEM_FN_VAR), widget_impl(SettingsEditor))]
+pub fn category_item_fn(child: impl UiNode, wgt_fn: impl IntoVar<WidgetFn<CategoryItemArgs>>) -> impl UiNode {
+    with_context_var(child, CATEGORY_ITEM_FN_VAR, wgt_fn)
+}
+
+/// Widget function that converts [`CategoriesListArgs`] to a category list.
+///
+/// Sets the [`CATEGORIES_LIST_FN_VAR`].
+#[property(CONTEXT, default(CATEGORIES_LIST_FN_VAR), widget_impl(SettingsEditor))]
+pub fn categories_list_fn(child: impl UiNode, wgt_fn: impl IntoVar<WidgetFn<CategoriesListArgs>>) -> impl UiNode {
+    with_context_var(child, CATEGORIES_LIST_FN_VAR, wgt_fn)
+}
+
+/// Widget function that converts [`CategoryHeaderArgs`] to a category settings header.
+///
+/// Sets the [`CATEGORY_HEADER_FN_VAR`].
+#[property(CONTEXT, default(CATEGORY_HEADER_FN_VAR), widget_impl(SettingsEditor))]
+pub fn category_header_fn(child: impl UiNode, wgt_fn: impl IntoVar<WidgetFn<CategoryHeaderArgs>>) -> impl UiNode {
+    with_context_var(child, CATEGORY_HEADER_FN_VAR, wgt_fn)
+}
+
+/// Widget function that converts [`SettingArgs`] to a setting editor entry on a settings list.
+///
+/// Note that the widget must set [`setting`] or some features will not work.
+///
+/// Sets the [`SETTING_FN_VAR`].
+///
+/// [`setting`]: fn@setting
+#[property(CONTEXT, default(SETTING_FN_VAR), widget_impl(SettingsEditor))]
+pub fn setting_fn(child: impl UiNode, wgt_fn: impl IntoVar<WidgetFn<SettingArgs>>) -> impl UiNode {
+    with_context_var(child, SETTING_FN_VAR, wgt_fn)
+}
+
+/// Widget function that converts [`SettingsArgs`] to a settings list.
+///
+/// Sets the [`SETTINGS_FN_VAR`].
+#[property(CONTEXT, default(SETTINGS_FN_VAR), widget_impl(SettingsEditor))]
+pub fn settings_fn(child: impl UiNode, wgt_fn: impl IntoVar<WidgetFn<SettingsArgs>>) -> impl UiNode {
+    with_context_var(child, SETTINGS_FN_VAR, wgt_fn)
+}
+
+/// Widget function that converts [`SettingsSearchArgs`] to a search box.
+///
+/// Sets the [`SETTINGS_SEARCH_FN_VAR`].
+#[property(CONTEXT, default(SETTINGS_SEARCH_FN_VAR), widget_impl(SettingsEditor))]
+pub fn settings_search_fn(child: impl UiNode, wgt_fn: impl IntoVar<WidgetFn<SettingsSearchArgs>>) -> impl UiNode {
+    with_context_var(child, SETTINGS_SEARCH_FN_VAR, wgt_fn)
+}
+
+/// Default category item view.
+///
+/// See [`CATEGORY_ITEM_FN_VAR`] for more details.
+pub fn default_category_item_fn(args: CategoryItemArgs) -> impl UiNode {
+    Toggle! {
+        child = Text!(args.category.name().clone());
+        value::<CategoryId> = args.category.id().clone();
+    }
+}
+
+/// Default category item view.
+///
+/// See [`CATEGORY_HEADER_FN_VAR`] for more details.
+pub fn default_category_header_fn(args: CategoryHeaderArgs) -> impl UiNode {
+    Text! {
+        txt = args.category.name().clone();
+        font_size = 1.5.em();
+        zng_wgt::margin = (10, 10, 10, 28);
+    }
+}
+
+/// Default categories list view.
+///
+/// See [`CATEGORIES_LIST_FN_VAR`] for more details.
+pub fn default_categories_list_fn(args: CategoriesListArgs) -> impl UiNode {
+    Container! {
+        child = Scroll! {
+            mode = ScrollMode::VERTICAL;
+            child_align = Align::FILL_TOP;
+            padding = (10, 20);
+            child = Stack! {
+                zng_wgt_toggle::selector = Selector::single(args.selected);
+                direction = StackDirection::top_to_bottom();
+                children = args.items;
+                zng_wgt_toggle::style_fn = Style! {
+                    replace = true;
+                    opacity = 70.pct();
+                    zng_wgt_size_offset::height = 2.em();
+                    zng_wgt_container::child_align = Align::START;
+                    zng_wgt_input::cursor = zng_wgt_input::CursorIcon::Pointer;
+
+                    when *#zng_wgt_input::is_cap_hovered {
+                        zng_wgt_text::font_weight = FontWeight::MEDIUM;
+                    }
+
+                    when *#zng_wgt_toggle::is_checked {
+                        zng_wgt_text::font_weight = FontWeight::BOLD;
+                        opacity = 100.pct();
+                    }
+                };
+            };
+        };
+        child_end = Vr!(zng_wgt::margin = 0), 0;
+    }
+}
+
+/// Default setting item view.
+pub fn default_setting_fn(args: SettingArgs) -> impl UiNode {
+    let name = args.setting.name().clone();
+    let description = args.setting.description().clone();
+    let can_reset = args.setting.can_reset();
+    Container! {
+        setting = args.setting.clone();
+
+        zng_wgt_input::focus::focus_scope = true;
+        zng_wgt_input::focus::focus_scope_behavior = zng_ext_input::focus::FocusScopeOnFocus::FirstDescendant;
+
+        child_start = {
+            let s = args.setting;
+            Wgt! {
+                zng_wgt::align = Align::TOP;
+                zng_wgt::visibility = can_reset.map(|c| match c {
+                    true => Visibility::Visible,
+                    false => Visibility::Hidden,
+                });
+                zng_wgt_input::gesture::on_click = hn!(|_| {
+                    s.reset();
+                });
+
+                zng_wgt_fill::background = ICONS.req_or("settings-reset", || Text!("R"));
+                zng_wgt_size_offset::size = 18;
+
+                tooltip = Tip!(Text!("reset"));
+                disabled_tooltip = Tip!(Text!("is default"));
+
+                zng_wgt_input::focus::tab_index = zng_ext_input::focus::TabIndex::SKIP;
+
+                opacity = 70.pct();
+                when *#zng_wgt_input::is_cap_hovered {
+                    opacity = 100.pct();
+                }
+                when *#zng_wgt::is_disabled {
+                    opacity = 30.pct();
+                }
+            }
+        }, 4;
+        child_top = Container! {
+            child_top = Text! {
+                txt = name;
+                font_weight = FontWeight::BOLD;
+            }, 4;
+            child = Markdown! {
+                txt = description;
+                opacity = 70.pct();
+            };
+        }, 5;
+        child = args.editor;
+    }
+}
+
+/// Default settings for a category view.
+pub fn default_settings_fn(args: SettingsArgs) -> impl UiNode {
+    Container! {
+        child_top = args.header, 5;
+        child = Scroll! {
+            mode = ScrollMode::VERTICAL;
+            padding = (0, 20, 20, 10);
+            child_align = Align::FILL_TOP;
+            child = Stack! {
+                direction = StackDirection::top_to_bottom();
+                spacing = 10;
+                children = args.items;
+            };
+        };
+    }
+}
+
+/// Default settings search box.
+pub fn default_settings_search_fn(args: SettingsSearchArgs) -> impl UiNode {
+    Container! {
+        child = TextInput! {
+            txt = args.search;
+        };
+        child_bottom = Hr!(zng_wgt::margin = (10, 10, 0, 10)), 0;
+    }
+}
+
+/// Arguments for a widget function that makes a category item for a categories list.
+pub struct CategoryItemArgs {
+    /// Index on the list.
+    pub index: usize,
+    /// The category.
+    pub category: Category,
+}
+
+/// Arguments for a widget function that makes a category header in a settings list.
+pub struct CategoryHeaderArgs {
+    /// The category.
+    pub category: Category,
+}
+
+/// Arguments for a widget function that makes a list of category items that can be selected.
+pub struct CategoriesListArgs {
+    /// The item views.
+    pub items: UiNodeVec,
+    /// The selected item.
+    pub selected: ArcVar<CategoryId>,
+}
+
+/// Arguments for a widget function that makes a setting container.
+pub struct SettingArgs {
+    /// Index of the setting on the list.
+    pub index: usize,
+    /// The setting.
+    pub setting: Setting,
+    /// The setting value editor.
+    pub editor: BoxedUiNode,
+}
+
+/// Arguments for a widget function that makes a settings for a category list.
+pub struct SettingsArgs {
+    /// The category header.
+    pub header: BoxedUiNode,
+    /// The items.
+    pub items: UiNodeVec,
+}
+
+/// Arguments for a search box widget.
+pub struct SettingsSearchArgs {
+    /// Search that matches setting name and descriptions.
+    pub search: ArcVar<Txt>,
+}
+
+/// Extends [`SettingBuilder`] to set custom editor metadata.
+pub trait SettingBuilderEditorExt {
+    /// Custom editor for the setting.
+    ///
+    /// If an editor is set the `VAR_EDITOR` service is used to instantiate the editor.
+    fn editor_fn(&mut self, editor: WidgetFn<Setting>) -> &mut Self;
+}
+
+/// Extends [`Setting`] to get custom editor metadata.
+pub trait SettingEditorExt {
+    /// Custom editor for the setting.
+    fn editor_fn(&self) -> Option<WidgetFn<Setting>>;
+
+    /// Instantiate editor.
+    ///
+    /// If an editor is set the [`EDITORS`] service is used to instantiate the editor.
+    fn editor(&self) -> BoxedUiNode;
+}
+
+/// Extends [`WidgetInfo`] to provide the setting config key for setting widgets.
+pub trait WidgetInfoSettingExt {
+    /// Gets the setting config key, if this widget represents a setting item.
+    fn setting_key(&self) -> Option<ConfigKey>;
+}
+
+static_id! {
+    static ref CUSTOM_EDITOR_ID: StateId<WidgetFn<Setting>>;
+    static ref SETTING_KEY_ID: StateId<ConfigKey>;
+}
+
+impl<'a> SettingBuilderEditorExt for SettingBuilder<'a> {
+    fn editor_fn(&mut self, editor: WidgetFn<Setting>) -> &mut Self {
+        self.set(*CUSTOM_EDITOR_ID, editor)
+    }
+}
+
+impl SettingEditorExt for Setting {
+    fn editor_fn(&self) -> Option<WidgetFn<Setting>> {
+        self.meta().get_clone(*CUSTOM_EDITOR_ID)
+    }
+
+    fn editor(&self) -> BoxedUiNode {
+        match self.editor_fn() {
+            Some(f) => f(self.clone()),
+            None => EDITOR_SETTING_VAR.with_context_var(ContextInitHandle::current(), Some(self.clone()), || {
+                EDITORS.get(self.value().clone_any())
+            }),
+        }
+    }
+}
+
+impl WidgetInfoSettingExt for WidgetInfo {
+    fn setting_key(&self) -> Option<ConfigKey> {
+        self.meta().get_clone(*SETTING_KEY_ID)
+    }
+}
+
+/// Extends [`SETTINGS`] to provide contextual information in an editor.
+pub trait SettingsCtxExt {
+    /// Gets a context var that tracks the [`Setting`] entry the widget is inside, or will be.
+    fn editor_setting(&self) -> ReadOnlyContextVar<Option<Setting>>;
+}
+impl SettingsCtxExt for SETTINGS {
+    fn editor_setting(&self) -> ReadOnlyContextVar<Option<Setting>> {
+        EDITOR_SETTING_VAR.read_only()
+    }
+}
+
+context_var! {
+    static EDITOR_SETTING_VAR: Option<Setting> = None;
+}
+
+/// Identifies the [`setting_fn`] widget.
+///
+/// [`setting_fn`]: fn@setting_fn
+#[property(CONTEXT)]
+pub fn setting(child: impl UiNode, setting: impl IntoValue<Setting>) -> impl UiNode {
+    let setting = setting.into();
+
+    let child = match_node(child, |_, op| {
+        if let UiNodeOp::Info { info } = op {
+            info.set_meta(*SETTING_KEY_ID, EDITOR_SETTING_VAR.with(|s| s.as_ref().unwrap().key().clone()));
+        }
+    });
+    with_context_var(child, EDITOR_SETTING_VAR, Some(setting))
+}
diff --git a/crates/zng-wgt-text/src/icon.rs b/crates/zng-wgt-text/src/icon.rs
index 07d07519c..7883ec7cb 100644
--- a/crates/zng-wgt-text/src/icon.rs
+++ b/crates/zng-wgt-text/src/icon.rs
@@ -4,7 +4,7 @@ use zng_app::{
     event::{CommandMetaVar, CommandMetaVarId},
     static_id,
 };
-use zng_ext_font::{font_features::FontFeatures, FontName};
+use zng_ext_font::{font_features::FontFeatures, FontName, FontSize};
 use zng_wgt::prelude::*;
 
 use std::fmt;
@@ -138,7 +138,7 @@ context_var! {
     /// Defines the size of an icon.
     ///
     /// Default is `24.dip()`.
-    pub static ICON_SIZE_VAR: Length = 24.dip();
+    pub static ICON_SIZE_VAR: FontSize = 24.dip();
 
     /// Defines the color of an icon.
     ///
@@ -150,7 +150,7 @@ context_var! {
 
 /// Sets the [`ICON_SIZE_VAR`] that affects all icons inside the widget.
 #[property(CONTEXT, default(ICON_SIZE_VAR), widget_impl(Icon))]
-pub fn ico_size(child: impl UiNode, size: impl IntoVar<Length>) -> impl UiNode {
+pub fn ico_size(child: impl UiNode, size: impl IntoVar<FontSize>) -> impl UiNode {
     with_context_var(child, ICON_SIZE_VAR, size)
 }
 
diff --git a/crates/zng-wgt/src/func.rs b/crates/zng-wgt/src/func.rs
index 92a8f2187..3d5b2d426 100644
--- a/crates/zng-wgt/src/func.rs
+++ b/crates/zng-wgt/src/func.rs
@@ -1,7 +1,8 @@
-use std::{fmt, ops, sync::Arc};
+use std::{any::TypeId, fmt, ops, sync::Arc};
 
 use crate::prelude::*;
 
+use zng_var::AnyVar;
 #[doc(hidden)]
 pub use zng_wgt::prelude::clmv as __clmv;
 
@@ -115,6 +116,14 @@ impl<D> WidgetFn<D> {
         let widget = ArcNode::new(widget);
         Self::new(move |_| widget.take_on_init())
     }
+
+    /// Creates a [`WeakWidgetFn<D>`] reference to this function.
+    pub fn downgrade(&self) -> WeakWidgetFn<D> {
+        match &self.0 {
+            Some(f) => WeakWidgetFn(Arc::downgrade(f)),
+            None => WeakWidgetFn::nil(),
+        }
+    }
 }
 impl<D: 'static> ops::Deref for WidgetFn<D> {
     type Target = dyn Fn(D) -> BoxedUiNode;
@@ -130,6 +139,28 @@ fn nil_call<D>(_: D) -> BoxedUiNode {
     NilUiNode.boxed()
 }
 
+/// Weak reference to a [`WidgetFn<D>`].
+pub struct WeakWidgetFn<D>(std::sync::Weak<BoxedWgtFn<D>>);
+impl<D> WeakWidgetFn<D> {
+    /// New weak reference to nil.
+    pub const fn nil() -> Self {
+        WeakWidgetFn(std::sync::Weak::new())
+    }
+
+    /// If this weak reference only upgrades to a nil function.
+    pub fn is_nil(&self) -> bool {
+        self.0.strong_count() == 0
+    }
+
+    /// Upgrade to strong reference if it still exists or nil.
+    pub fn upgrade(&self) -> WidgetFn<D> {
+        match self.0.upgrade() {
+            Some(f) => WidgetFn(Some(f)),
+            None => WidgetFn::nil(),
+        }
+    }
+}
+
 /// <span data-del-macro-root></span> Declares a widget function closure.
 ///
 /// The output type is a [`WidgetFn`], the closure is [`clmv!`].
@@ -195,3 +226,216 @@ macro_rules! wgt_fn {
         $crate::WidgetFn::nil()
     };
 }
+
+/// Service that provides editor widgets for a given variable.
+///
+/// Auto generating widgets such as a settings list or a properties list can use this
+/// service to instantiate widgets for each item.
+///
+/// The main crate registers some common editors.
+#[allow(non_camel_case_types)]
+pub struct EDITORS;
+impl EDITORS {
+    /// Register an `editor` handler.
+    ///
+    /// The handler must return [`NilUiNode`] if it cannot handle the request. Later added handlers are called first.
+    pub fn register(&self, editor: WidgetFn<EditorRequestArgs>) {
+        if !editor.is_nil() {
+            UPDATES
+                .run(async move {
+                    EDITORS_SV.write().push(editor);
+                })
+                .perm();
+        }
+    }
+
+    /// Register an `editor` handler to be called if none of the `register` editors can handle the value.
+    ///
+    /// The handler must return [`NilUiNode`] if it cannot handle the request. Later added handlers are called last.
+    pub fn register_fallback(&self, editor: WidgetFn<EditorRequestArgs>) {
+        if !editor.is_nil() {
+            UPDATES
+                .run(async move {
+                    EDITORS_SV.write().push_fallback(editor);
+                })
+                .perm();
+        }
+    }
+
+    /// Instantiate an editor for the `value`.
+    ///
+    /// Returns [`NilUiNode`] if no registered editor can handle the value type.
+    pub fn get(&self, value: impl AnyVar) -> BoxedUiNode {
+        EDITORS_SV.read().get(EditorRequestArgs { value: Box::new(value) })
+    }
+
+    /// Same as [`get`], but also logs an error is there are no available editor for the type.
+    ///
+    /// [`get`]: Self::get
+    pub fn req<T: VarValue>(&self, value: impl Var<T>) -> BoxedUiNode {
+        let e = self.get(value);
+        if e.is_nil() {
+            tracing::error!("no editor available for `{}`", std::any::type_name::<T>())
+        }
+        e
+    }
+}
+
+/// Service that provides icon drawing widgets.
+///
+/// This service enables widgets to use icons in an optional way, without needing to bundle icon resources. It
+/// also enables app wide icon theming.
+pub struct ICONS;
+impl ICONS {
+    /// Register an `icon` handler.
+    ///
+    /// The handler must return [`NilUiNode`] if it cannot handle the request. Later added handlers are called first.
+    pub fn register(&self, icon: WidgetFn<IconRequestArgs>) {
+        if !icon.is_nil() {
+            UPDATES
+                .run(async move {
+                    ICONS_SV.write().push(icon);
+                })
+                .perm();
+        }
+    }
+
+    /// Register an `icon` handler to be called if none of the `register` handlers can handle request.
+    ///
+    /// The handler must return [`NilUiNode`] if it cannot handle the request. Later added handlers are called last.
+    pub fn register_fallback(&self, icon: WidgetFn<IconRequestArgs>) {
+        if !icon.is_nil() {
+            UPDATES
+                .run(async move {
+                    ICONS_SV.write().push_fallback(icon);
+                })
+                .perm();
+        }
+    }
+
+    /// Instantiate an icon drawing widget for the `icon_name`.
+    ///
+    /// Returns [`NilUiNode`] if no registered handler can provide an icon.
+    pub fn get(&self, icon_name: impl Into<Txt>) -> BoxedUiNode {
+        ICONS_SV.read().get(IconRequestArgs { name: icon_name.into() })
+    }
+
+    /// Instantiate an icon drawing widget for the `icon_name` or call `fallback` to do it
+    /// if no handler can handle the request.
+    pub fn get_or<U: UiNode>(&self, icon_name: impl Into<Txt>, fallback: impl FnOnce() -> U) -> BoxedUiNode {
+        let i = self.get(icon_name.into());
+        if i.is_nil() {
+            fallback().boxed()
+        } else {
+            i
+        }
+    }
+
+    /// Same as [`get`], but also logs an error is there are no available icon for the name.
+    ///
+    /// [`get`]: Self::get
+    pub fn req(&self, icon_name: impl Into<Txt>) -> BoxedUiNode {
+        let name = icon_name.into();
+        let i = self.get(name.clone());
+        if i.is_nil() {
+            tracing::error!("no icon available for {name:?}")
+        }
+        i
+    }
+
+    //// Same as [`get_or`], but also logs an error is there are no available icon for the name.
+    ///
+    /// [`get_or`]: Self::get_or
+    pub fn req_or<U: UiNode>(&self, icon_name: impl Into<Txt>, fallback: impl FnOnce() -> U) -> BoxedUiNode {
+        let i = self.req(icon_name.into());
+        if i.is_nil() {
+            fallback().boxed()
+        } else {
+            i
+        }
+    }
+}
+
+/// Arguments for [`EDITORS.register`].
+///
+/// Note that the handler is usually called in the widget context that will host the editor, so context
+/// variables and services my also be available to inform the editor preferences.
+///
+/// [`EDITORS.register`]: EDITORS::register
+#[derive(Clone)]
+pub struct EditorRequestArgs {
+    value: Box<dyn AnyVar>,
+}
+impl EditorRequestArgs {
+    /// The value variable.
+    pub fn value_any(&self) -> &dyn AnyVar {
+        &self.value
+    }
+
+    /// Try to downcast the value variable to `T`.
+    pub fn value<T: VarValue>(&self) -> Option<BoxedVar<T>> {
+        if self.value.var_type_id() == TypeId::of::<T>() {
+            let value = *self.value.clone_any().double_boxed_any().downcast::<BoxedVar<T>>().ok()?;
+            return Some(value);
+        }
+        None
+    }
+}
+
+/// Arguments for [`ICONS.register`].
+///
+/// Note that the handler is usually called in the widget context that will host the editor, so context
+/// variables and services my also be available to inform the editor preferences.
+///
+/// [`ICONS.register`]: ICONS::register
+#[derive(Clone)]
+pub struct IconRequestArgs {
+    name: Txt,
+}
+impl IconRequestArgs {
+    /// Icon unique name,
+    pub fn name(&self) -> &str {
+        &self.name
+    }
+}
+
+app_local! {
+    static EDITORS_SV: WidgetProviderService<EditorRequestArgs> = const { WidgetProviderService::new() };
+    static ICONS_SV: WidgetProviderService<IconRequestArgs> = const { WidgetProviderService::new() };
+}
+struct WidgetProviderService<A> {
+    handlers: Vec<WidgetFn<A>>,
+    fallback: Vec<WidgetFn<A>>,
+}
+impl<A: Clone + 'static> WidgetProviderService<A> {
+    const fn new() -> Self {
+        Self {
+            handlers: vec![],
+            fallback: vec![],
+        }
+    }
+
+    fn push(&mut self, handler: WidgetFn<A>) {
+        self.handlers.push(handler);
+    }
+
+    fn push_fallback(&mut self, handler: WidgetFn<A>) {
+        self.fallback.push(handler);
+    }
+
+    fn get(&self, args: A) -> BoxedUiNode {
+        for handler in self.handlers.iter().rev() {
+            let editor = handler(args.clone());
+            if !editor.is_nil() {
+                return editor;
+            }
+        }
+        for handler in self.fallback.iter() {
+            let editor = handler(args.clone());
+            if !editor.is_nil() {
+                return editor;
+            }
+        }
+        NilUiNode.boxed()
+    }
+}
diff --git a/crates/zng/Cargo.toml b/crates/zng/Cargo.toml
index f35ac3119..9c922bead 100644
--- a/crates/zng/Cargo.toml
+++ b/crates/zng/Cargo.toml
@@ -20,7 +20,12 @@ view = ["dep:zng-view"]
 view_prebuilt = ["dep:zng-view-prebuilt", "ipc"]
 
 # Enables HTTP tasks and web features of widgets and services.
-http = ["zng-task/http", "zng-wgt/http", "zng-ext-image/http", "zng-ext-window/http"]
+http = [
+    "zng-task/http",
+    "zng-wgt/http",
+    "zng-ext-image/http",
+    "zng-ext-window/http",
+]
 
 # Enable the `"dyn_*"`, `"inspector"` features in debug builds.
 debug_default = [
@@ -229,6 +234,7 @@ zng-wgt-ansi-text = { path = "../zng-wgt-ansi-text", version = "0.3.9" }
 zng-wgt-tooltip = { path = "../zng-wgt-tooltip", version = "0.3.9" }
 zng-wgt-markdown = { path = "../zng-wgt-markdown", version = "0.3.9" }
 zng-wgt-inspector = { path = "../zng-wgt-inspector", version = "0.2.20" }
+zng-wgt-settings = { path = "../zng-wgt-settings", version = "0.1.0" }
 
 zng-wgt-material-icons = { path = "../zng-wgt-material-icons", version = "0.2.20", default-features = false, optional = true }
 zng-ext-single-instance = { path = "../zng-ext-single-instance", version = "0.3.4", optional = true }
diff --git a/crates/zng/src/config.rs b/crates/zng/src/config.rs
index 8f244bbe9..11ae3d6dc 100644
--- a/crates/zng/src/config.rs
+++ b/crates/zng/src/config.rs
@@ -100,3 +100,25 @@ pub use zng_ext_config::TomlConfig;
 
 #[cfg(feature = "yaml")]
 pub use zng_ext_config::YamlConfig;
+
+/// Settings are the config the user can directly edit, this module implements a basic settings data model.
+///
+/// # Full API
+///
+/// See [`zng_ext_config::settings`] for the full settings API.
+pub mod settings {
+    pub use zng_ext_config::settings::{CategoriesBuilder, Category, CategoryBuilder, Setting, SettingBuilder, SettingsBuilder, SETTINGS};
+    pub use zng_wgt_input::cmd::{on_pre_settings, on_settings, SETTINGS_CMD};
+
+    /// Settings editor widget.
+    ///
+    /// # Full API
+    ///
+    /// See [`zng_wgt_settings`] for the full settings editor API.
+    pub mod editor {
+        pub use zng_wgt_settings::{
+            categories_list_fn, category_header_fn, category_item_fn, setting_fn, settings_fn, CategoriesListArgs, CategoryHeaderArgs,
+            CategoryItemArgs, SettingArgs, SettingsArgs, SettingsEditor,
+        };
+    }
+}
diff --git a/crates/zng/src/icon.rs b/crates/zng/src/icon.rs
index b4845cd01..70d61d200 100644
--- a/crates/zng/src/icon.rs
+++ b/crates/zng/src/icon.rs
@@ -1,17 +1,34 @@
-//! Icon widget and other types.
+//! Icons service, icon font widget and other types.
 //!
-//! Icons for this widget are defined in a text font. The [Material Design Icons]
-//! can be embedded using the crate feature `"material_icons"`.
+//! # Service
 //!
-//! [Material Design Icons]: https://github.com/google/material-design-icons
+//! The [`ICONS`] service bridges icon providers and icon users. Icon theme providers can register
+//! handlers that provide a node that renders the icon identified by name. Widget styles or other UI
+//! only need to request the icon, avoiding having to embed icon resources in lib crates and avoiding
+//! icons having a fixed appearance.
 //!
-//! ```toml
-//! zng = { version = "0.9.1", features = ["material_icons"] }
 //! ```
+//! use zng::{prelude::*, icon, widget::node::NilUiNode};
+//! # let _scope = APP.defaults();
+//!
+//! icon::ICONS.register(wgt_fn!(|a: icon::IconRequestArgs| {
+//!     match a.name() {
+//!         "accessibility" => icon::Icon!(icon::material_rounded::ACCESSIBILITY).boxed(),
+//!         "settings" => icon::Icon!(icon::material_rounded::SETTINGS).boxed(),
+//!         _ => NilUiNode.boxed()
+//!     }
+//! }));
+//! ```
+//!
+//! The example above registers a handler that provides two icons.
+//!
+//! # Widget
+//!
+//! The [`Icon!`](struct@Icon) widget renders icons using an icon font.
 //!
 //! ```
 //! use zng::{prelude::*, icon};
-//! # let _ = APP.defaults();
+//! # let _scope = APP.defaults();
 //!
 //! # let _ =
 //! icon::Icon! {
@@ -29,7 +46,7 @@
 //! # fn main() { }
 //! use zng::{prelude::*, icon, font};
 //! # async fn demo() {
-//! # let _ = APP.defaults();
+//! # let _scope = APP.defaults();
 //!
 //! let font = font::CustomFont::from_file(
 //!     "Font Awesome 6 Free-Regular",
@@ -49,6 +66,18 @@
 //!
 //! The example above loads an icon font and display one of the icons selected using a ligature that matches `"address-book"`.
 //!
+//! # Material Icons
+//!
+//! The [Material Design Icons] can be embedded using the crate feature `"material_icons"`.
+//!
+//! [Material Design Icons]: https://github.com/google/material-design-icons
+//!
+//! ```toml
+//! zng = { version = "0.9.1", features = ["material_icons"] }
+//! ```
+//! Note that if `"material_icons_outlined"` feature is enabled the default `APP` will register an [`ICONS`] handler that provides
+//! many of the icons needed by the Zng widgets and apps in general.
+//!
 //! # Full API
 //!
 //! See [`zng_wgt_text::icon`] for the full widget API.
@@ -71,3 +100,5 @@ pub use zng_wgt_material_icons::outlined as material_outlined;
 pub use zng_wgt_material_icons::rounded as material_rounded;
 #[cfg(feature = "material_icons_sharp")]
 pub use zng_wgt_material_icons::sharp as material_sharp;
+
+pub use zng_wgt::{IconRequestArgs, ICONS};
diff --git a/crates/zng/src/lib.rs b/crates/zng/src/lib.rs
index 4e012ead6..972e4f6e6 100644
--- a/crates/zng/src/lib.rs
+++ b/crates/zng/src/lib.rs
@@ -812,6 +812,8 @@ mod defaults {
     use zng_ext_undo::UndoManager;
     use zng_ext_window::WindowManager;
 
+    use crate::default_editors;
+
     #[cfg(feature = "dyn_app_extension")]
     macro_rules! DefaultsAppExtended {
         () => {
@@ -898,6 +900,11 @@ mod defaults {
     struct DefaultsInit {}
     impl AppExtension for DefaultsInit {
         fn init(&mut self) {
+            // Common editors.
+            zng_wgt::EDITORS.register_fallback(zng_wgt::WidgetFn::new(default_editors::handler));
+            tracing::debug!("defaults init, var_editor set");
+
+            // injected in all windows
             zng_ext_window::WINDOWS.register_root_extender(|a| {
                 let child = a.root;
 
@@ -915,6 +922,7 @@ mod defaults {
             });
             tracing::debug!("defaults init, root_extender set");
 
+            // setup OPEN_LICENSES_CMD handler
             crate::third_party::setup_default_view();
             tracing::debug!("defaults init, third_party set");
 
@@ -942,10 +950,11 @@ mod defaults {
 
             #[cfg(feature = "material_icons_outlined")]
             {
+                use zng_app::widget::node::{NilUiNode, UiNode};
                 use zng_ext_clipboard::*;
                 use zng_ext_undo::*;
                 use zng_ext_window::cmd::*;
-                use zng_wgt::wgt_fn;
+                use zng_wgt::{wgt_fn, IconRequestArgs, ICONS};
                 use zng_wgt_input::cmd::*;
                 use zng_wgt_material_icons::outlined as icons;
                 use zng_wgt_scroll::cmd::*;
@@ -976,6 +985,14 @@ mod defaults {
                 ZOOM_OUT_CMD.init_icon(wgt_fn!(|_| Icon!(icons::ZOOM_OUT)));
 
                 OPEN_CMD.init_icon(wgt_fn!(|_| Icon!(icons::FILE_OPEN)));
+                SETTINGS_CMD.init_icon(wgt_fn!(|_| Icon!(icons::SETTINGS)));
+
+                ICONS.register(wgt_fn!(|args: IconRequestArgs| {
+                    match args.name() {
+                        "settings-reset" => Icon!(icons::SETTINGS_BACKUP_RESTORE).boxed(),
+                        _ => NilUiNode.boxed(),
+                    }
+                }));
 
                 tracing::debug!("defaults init, command_icons set");
             }
@@ -986,3 +1003,64 @@ mod defaults {
 #[doc = include_str!("../../README.md")]
 #[cfg(doctest)]
 pub mod read_me_test {}
+
+mod default_editors {
+    use zng::{
+        prelude::*,
+        text_input,
+        widget::{
+            node::{BoxedUiNode, NilUiNode},
+            EditorRequestArgs,
+        },
+    };
+
+    pub fn handler(args: EditorRequestArgs) -> BoxedUiNode {
+        if let Some(txt) = args.value::<Txt>() {
+            return TextInput! {
+                txt;
+            }
+            .boxed();
+        }
+        if let Some(s) = args.value::<String>() {
+            return TextInput! {
+                txt = s.map_bidi(|s| Txt::from_str(s), |t: &Txt| t.to_string());
+            }
+            .boxed();
+        }
+        if let Some(c) = args.value::<char>() {
+            return TextInput! {
+                txt_parse::<char> = c;
+                style_fn = text_input::FieldStyle!();
+            }
+            .boxed();
+        }
+
+        if let Some(checked) = args.value::<bool>() {
+            return Toggle! {
+                style_fn = toggle::CheckStyle!();
+                checked;
+            }
+            .boxed();
+        }
+
+        macro_rules! parse {
+            ($($ty:ty),+ $(,)?) => {
+                $(
+                    if let Some(n) = args.value::<$ty>() {
+                        return TextInput! {
+                            txt_parse::<$ty> = n;
+                            style_fn = text_input::FieldStyle!();
+                        }
+                        .boxed();
+                    }
+
+                )+
+            }
+        }
+        parse! {
+            u8, i8, u16, i16, u32, i32, u64, i64, u128, i128, f32, f64,
+        }
+
+        NilUiNode.boxed()
+    }
+}
diff --git a/crates/zng/src/widget.rs b/crates/zng/src/widget.rs
index 14dd13d82..db46d1067 100644
--- a/crates/zng/src/widget.rs
+++ b/crates/zng/src/widget.rs
@@ -54,7 +54,8 @@ pub use zng_wgt::{
     on_interactivity_changed, on_move, on_node_op, on_pre_block, on_pre_blocked_changed, on_pre_deinit, on_pre_disable, on_pre_enable,
     on_pre_enabled_changed, on_pre_init, on_pre_interactivity_changed, on_pre_move, on_pre_node_op, on_pre_transform_changed,
     on_pre_unblock, on_pre_update, on_pre_vis_disable, on_pre_vis_enable, on_pre_vis_enabled_changed, on_transform_changed, on_unblock,
-    on_update, on_vis_disable, on_vis_enable, on_vis_enabled_changed, parallel, visibility, wgt_fn, z_index, OnNodeOpArgs, Wgt, WidgetFn,
+    on_update, on_vis_disable, on_vis_enable, on_vis_enabled_changed, parallel, visibility, wgt_fn, z_index, EditorRequestArgs,
+    OnNodeOpArgs, WeakWidgetFn, Wgt, WidgetFn, EDITORS,
 };
 
 pub use zng_wgt_fill::{
diff --git a/examples/config/res/default-settings.json b/examples/config/res/default-settings.json
new file mode 100644
index 000000000..cfc2e567a
--- /dev/null
+++ b/examples/config/res/default-settings.json
@@ -0,0 +1,3 @@
+{
+    "bool": true
+}
\ No newline at end of file
diff --git a/examples/config/res/defaults.json b/examples/config/res/defaults.json
deleted file mode 100644
index 25ad0050c..000000000
--- a/examples/config/res/defaults.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
-  "count": 0,
-  "checked": false,
-  "txt": "defaults.json"
-}
\ No newline at end of file
diff --git a/examples/config/src/main.rs b/examples/config/src/main.rs
index 87505fd4b..51412a54a 100644
--- a/examples/config/src/main.rs
+++ b/examples/config/src/main.rs
@@ -1,15 +1,8 @@
-//! Demonstrates the CONFIG service, live updating config between processes.
+//! Demonstrates the CONFIG and SETTINGS services, live updating config between processes.
 
-use zng::{
-    color::filter::opacity,
-    icon::{material_outlined as icons, Icon},
-    layout::{align, margin},
-    prelude::*,
-    var::BoxedVar,
-    widget::LineStyle,
-};
+use zng::{layout::align, prelude::*};
 
-use zng::config::*;
+use zng::config::{settings::SETTINGS, *};
 
 fn main() {
     zng::env::init_res(concat!(env!("CARGO_MANIFEST_DIR"), "/res"));
@@ -17,146 +10,119 @@ fn main() {
     app_main();
 }
 
-fn load_config() -> Box<dyn FallbackConfigReset> {
-    // config file for the app, keys with prefix "main." are saved here.
-    let user_cfg = JsonConfig::sync(zng::env::config("example.config.json"));
-    // entries not found in `user_cfg` bind to this file first before going to embedded fallback.
-    let default_cfg = ReadOnlyConfig::new(JsonConfig::sync(zng::env::res("defaults.json")));
-
-    // the app settings.
-    let main_cfg = FallbackConfig::new(user_cfg, default_cfg);
+fn load_config() {
+    // settings file for the app, keys with prefix "settings." are saved here.
+    let user_settings = JsonConfig::sync(zng::env::config("example-config/settings.json"));
+    // entries not found in `user_settings` bind to this file first before going to embedded fallback.
+    let default_settings = ReadOnlyConfig::new(JsonConfig::sync(zng::env::res("default-settings.json")));
 
+    let settings = FallbackConfig::new(user_settings, default_settings);
     // Clone a ref that can be used to reset specific entries.
-    let main_ref = main_cfg.clone_boxed();
+    let settings_ref = settings.clone_boxed();
 
     // any other configs (Window::save_state for example)
-    let other_cfg = JsonConfig::sync(zng::env::config("example.config.other.json"));
-
-    CONFIG.load(SwitchConfig::new().with_prefix("main.", main_cfg).with_prefix("", other_cfg));
-
-    main_ref
-}
-
-fn config_editor<T: ConfigValue, E: UiNode>(
-    main_cfg_key: &'static str,
-    default: impl FnOnce() -> T,
-    main_cfg: Box<dyn FallbackConfigReset>,
-    editor: impl FnOnce(BoxedVar<T>) -> E,
-) -> impl UiNode {
-    let main_cfg_key = ConfigKey::from_static(main_cfg_key);
-
-    Container! {
-        child = editor(CONFIG.get(formatx!("main.{main_cfg_key}"), default));
-        child_start = {
-            node: Icon! {
-                widget::enabled = main_cfg.can_reset(main_cfg_key.clone());
-                gesture::on_click = hn!(|_| {
-                    main_cfg.reset(&main_cfg_key);
-                });
-
-                ico = icons::SETTINGS_BACKUP_RESTORE;
-                tooltip = Tip!(Text!("reset"));
-                tip::disabled_tooltip = Tip!(Text!("is default"));
-
-                ico_size = 18;
-
-                opacity = 70.pct();
-                when *#gesture::is_cap_hovered {
-                    opacity = 100.pct();
-                }
-                when *#widget::is_disabled {
-                    opacity = 30.pct();
-                }
-            },
-            spacing: 5,
+    let other_cfg = JsonConfig::sync(zng::env::config("example-config/config.json"));
+
+    // switch over configs, the prefix is striped from the inner configs.
+    CONFIG.load(SwitchConfig::new().with_prefix("settings.", settings).with_prefix("", other_cfg));
+
+    // register settings metadata
+    SETTINGS.register_categories(|c| {
+        c.entry("bool", |c| c.name("Booleans"))
+            .entry("integers", |c| c.name("Integers"))
+            .entry("floats", |c| c.name("Floats"))
+            .entry("strings", |c| c.name("Strings"));
+    });
+
+    SETTINGS.register(move |s| {
+        s.entry("settings.bool", "bool", |s| {
+            s.name("bool")
+                .description("Example *bool* value.")
+                .value(|| false)
+                .reset(settings_ref.clone_boxed(), "settings.")
+        });
+
+        macro_rules! examples {
+            ($([$ty:tt, $cat:tt, $default:expr]),+ $(,)?) => {
+                $(
+                    s.entry(concat!("settings.", $ty), $cat, |s| {
+                        s.name($ty)
+                            .description(concat!("Example *", $ty, "* value."))
+                            .value(|| $default)
+                            .reset(settings_ref.clone_boxed(), "settings.")
+                    });
+                )+
+            };
         }
-    }
+        examples! {
+            ["u8", "integers", 0u8],
+            ["u16", "integers", 0u16],
+            ["u32", "integers", 0u32],
+            ["u64", "integers", 0u64],
+            ["u128", "integers", 0u128],
+            ["i8", "integers", 0i8],
+            ["i16", "integers", 0i16],
+            ["i32", "integers", 0i32],
+            ["i64", "integers", 0i64],
+            ["i128", "integers", 0i128],
+
+            ["f32", "floats", 0f32],
+            ["f64", "floats", 0f64],
+
+            ["Txt", "strings", Txt::from("")],
+            ["String", "strings", String::new()],
+            ["Char", "strings", 'c'],
+        };
+    });
 }
 
 fn app_main() {
     APP.defaults().run_window(async {
-        let main_cfg = load_config();
-
-        let checked = config_editor(
-            "checked",
-            || false,
-            main_cfg.clone(),
-            |checked| {
-                Toggle! {
-                    child = Text!(checked.map(|c| formatx!("Checked: {c:?}")));
-                    checked = checked.clone();
-                }
-            },
-        );
-        let count = config_editor(
-            "count",
-            || 0,
-            main_cfg.clone(),
-            |count| {
-                Button! {
-                    child = Text!(count.map(|c| formatx!("Count: {c:?}")));
-                    on_click = hn!(count, |_| {
-                        count.modify(|c| *c.to_mut() += 1).unwrap();
-                    })
-                }
-            },
-        );
-        let txt = config_editor(
-            "txt",
-            || Txt::from_static(""),
-            main_cfg,
-            |txt| {
-                TextInput! {
-                    txt;
-                    layout::min_width = 100;
-                }
-            },
-        );
-
+        load_config();
         Window! {
-            title = if std::env::var("MOVE-TO").is_err() { "Config Example" } else { "Config Example - Other Process" };
+            title = if std::env::var("OTHER-PROCESS").is_err() { "Config Example" } else { "Config Example - Other Process" };
             size = (600, 500);
-            widget::background = Text! {
-                txt = CONFIG.status().map_to_txt();
-                margin = 10;
-                font_family = "monospace";
-                align = Align::TOP_LEFT;
-                font_weight = FontWeight::BOLD;
-
-                when *#{CONFIG.status().map(|s| s.is_err())} {
-                    font_color = colors::RED;
-                }
-            };
-            child = Stack! {
-                direction = StackDirection::top_to_bottom();
-                align = Align::CENTER;
-                spacing = 5;
-                children = ui_vec![
-                    checked,
-                    count,
-                    txt,
-                    separator(),
-                    Button! {
-                        child = Text!("Open Another Process");
-                        on_click = hn!(|_| {
-                            let offset = layout::Dip::new(30);
-                            let pos = WINDOW.vars().actual_position().get() + layout::DipVector::new(offset, offset);
-                            let pos = pos.to_i32();
-                            let r: Result<(), Box<dyn std::error::Error>> = (|| {
-                                let exe = dunce::canonicalize(std::env::current_exe()?)?;
-                                std::process::Command::new(exe).env("MOVE-TO", format!("{},{}", pos.x, pos.y)).spawn()?;
-                                Ok(())
-                            })();
-                            match r {
-                                Ok(_) => tracing::info!("Opened another process"),
-                                Err(e) => tracing::error!("Error opening another process, {e:?}"),
-                            }
-                        })
+            // settings editor, usually not on the main window
+            padding = 10;
+            child = zng::config::settings::editor::SettingsEditor!();
+            child_bottom = Container! {
+                child_top = Hr!(layout::margin = 0), 10;
+
+                // status
+                child_left = Text! {
+                    txt = CONFIG.status().map_to_txt();
+                    layout::margin = 10;
+                    font_family = "monospace";
+                    align = Align::TOP_LEFT;
+                    font_weight = FontWeight::BOLD;
+
+                    when *#{CONFIG.status().map(|s| s.is_err())} {
+                        font_color = colors::RED;
                     }
-                ];
-            };
+                }, 0;
+
+                // spawn another process to demonstrate the live update of configs
+                child_right = Button! {
+                    child = Text!("Open Another Process");
+                    on_click = hn!(|_| {
+                        let offset = layout::Dip::new(30);
+                        let pos = WINDOW.vars().actual_position().get() + layout::DipVector::new(offset, offset);
+                        let pos = pos.to_i32();
+                        let r: Result<(), Box<dyn std::error::Error>> = (|| {
+                            let exe = dunce::canonicalize(std::env::current_exe()?)?;
+                            std::process::Command::new(exe).env("OTHER-PROCESS", format!("{},{}", pos.x, pos.y)).spawn()?;
+                            Ok(())
+                        })();
+                        match r {
+                            Ok(_) => tracing::info!("Opened another process"),
+                            Err(e) => tracing::error!("Error opening another process, {e:?}"),
+                        }
+                    })
+                }, 0;
+            }, 10;
             on_load = hn_once!(|_| {
-                if let Ok(pos) = std::env::var("MOVE-TO") {
+                // window position is saved, so we move the second window a bit
+                if let Ok(pos) = std::env::var("OTHER-PROCESS") {
                     if let Some((x, y)) = pos.split_once(',') {
                         if let (Ok(x), Ok(y)) = (x.parse(), y.parse()) {
                             let pos = (layout::Dip::new(x), layout::Dip::new(y));
@@ -169,11 +135,3 @@ fn app_main() {
         }
     })
 }
-
-fn separator() -> impl UiNode {
-    Hr! {
-        color = rgba(1.0, 1.0, 1.0, 0.2);
-        margin = (0, 8);
-        line_style = LineStyle::Dashed;
-    }
-}


Test patch: