zng_wgt_settings/
lib.rs

1#![doc(html_favicon_url = "https://zng-ui.github.io/res/zng-logo-icon.png")]
2#![doc(html_logo_url = "https://zng-ui.github.io/res/zng-logo.png")]
3//! Settings widgets.
4
5zng_wgt::enable_widget_macros!();
6
7mod view_fn;
8pub use view_fn::*;
9
10use zng_ext_config::settings::{Category, CategoryId, SETTINGS};
11use zng_ext_input::focus::FOCUS;
12use zng_ext_l10n::l10n;
13use zng_ext_window::{WINDOW_Ext as _, WINDOWS};
14use zng_wgt::{node::VarPresent as _, prelude::*};
15use zng_wgt_input::cmd::SETTINGS_CMD;
16use zng_wgt_size_offset::actual_width;
17use zng_wgt_window::{SaveState, Window, save_state_node};
18
19/// Settings editor widget.
20#[widget($crate::SettingsEditor)]
21pub struct SettingsEditor(WidgetBase);
22impl SettingsEditor {
23    fn widget_intrinsic(&mut self) {
24        widget_set! {
25            self;
26            save_state = SaveState::enabled();
27            zng_wgt_fill::background_color = light_dark(rgb(0.85, 0.85, 0.85), rgb(0.15, 0.15, 0.15));
28            zng_wgt_container::padding = 10;
29
30            when *#actual_width <= 400 && *#actual_width > 1 {
31                panel_fn = WidgetFn::new(default_panel_mobile_fn);
32                categories_list_fn = WidgetFn::new(default_categories_list_mobile_fn);
33            }
34        }
35        self.widget_builder().push_build_action(|wgt| {
36            wgt.set_child(settings_editor_node());
37            wgt.push_intrinsic(NestGroup::EVENT, "command-handler", command_handler);
38            wgt.push_intrinsic(NestGroup::CONTEXT, "editor-vars", |child| {
39                let child = with_context_var_init(child, EDITOR_STATE_VAR, editor_state);
40                let child = with_context_var(child, EDITOR_SEARCH_VAR, var(Txt::from("")));
41                with_context_var(child, EDITOR_SELECTED_CATEGORY_VAR, var(CategoryId::from("")))
42            });
43        });
44    }
45}
46
47/// Implements the [`SettingsEditor!`] inner widgets.
48///
49/// [`SettingsEditor!`]: struct@SettingsEditor
50pub fn settings_editor_node() -> UiNode {
51    match_node(UiNode::nil(), move |c, op| match op {
52        UiNodeOp::Init => {
53            WIDGET
54                .sub_var(&SETTINGS_FN_VAR)
55                .sub_var(&SETTING_FN_VAR)
56                .sub_var(&SETTINGS_SEARCH_FN_VAR)
57                .sub_var(&CATEGORIES_LIST_FN_VAR)
58                .sub_var(&CATEGORY_HEADER_FN_VAR)
59                .sub_var(&CATEGORY_ITEM_FN_VAR)
60                .sub_var(&PANEL_FN_VAR);
61            *c.node() = settings_view_fn();
62        }
63        UiNodeOp::Deinit => {
64            c.deinit();
65            *c.node() = UiNode::nil();
66        }
67        UiNodeOp::Update { .. } => {
68            if PANEL_FN_VAR.is_new()
69                || SETTINGS_FN_VAR.is_new()
70                || SETTING_FN_VAR.is_new()
71                || SETTINGS_SEARCH_FN_VAR.is_new()
72                || CATEGORIES_LIST_FN_VAR.is_new()
73                || CATEGORY_HEADER_FN_VAR.is_new()
74                || CATEGORY_ITEM_FN_VAR.is_new()
75            {
76                c.delegated();
77                c.node().deinit();
78                *c.node() = settings_view_fn();
79                c.node().init();
80                WIDGET.update_info().layout().render();
81            }
82        }
83        _ => {}
84    })
85}
86
87fn editor_state() -> Var<Option<SettingsEditorState>> {
88    // avoids rebuilds for ignored search changes
89    let clean_search = SETTINGS.editor_search().current_context().map(|s| {
90        let s = s.trim();
91        if !s.starts_with('@') {
92            s.to_lowercase().into()
93        } else {
94            Txt::from_str(s)
95        }
96    });
97
98    let sel_cat = SETTINGS.editor_selected_category().current_context();
99    let r = expr_var! {
100        if #{clean_search}.is_empty() {
101            // no search, does not need to load settings of other categories
102            let (cat, settings) = SETTINGS
103                .get(|_, cat| cat == #{sel_cat}, true)
104                .pop()
105                .unwrap_or_else(|| (Category::unknown(#{sel_cat}.clone()), vec![]));
106            Some(SettingsEditorState {
107                clean_search: #{clean_search}.clone(),
108                categories: SETTINGS.categories(|_| true, false, true),
109                selected_cat: cat,
110                top_match: settings.first().map(|s| s.key().clone()).unwrap_or_default(),
111                selected_settings: settings,
112            })
113        } else {
114            // has search, just load everything
115            let mut r = SETTINGS.get(|_, _| true, false);
116
117            // apply search filter, get best match key (top_match), actual selected_cat.
118            let mut top_match = (usize::MAX, Txt::from(""));
119            let mut actual_cat = None;
120            r.retain_mut(|(c, s)| {
121                if c.id() == #{sel_cat} {
122                    // is selected cat
123                    actual_cat = Some(c.clone());
124                    // actually filter settings
125                    s.retain(|s| match s.search_index(#{clean_search}) {
126                        Some(i) => {
127                            if i < top_match.0 {
128                                top_match = (i, s.key().clone());
129                            }
130                            true
131                        }
132                        None => false,
133                    });
134                    !s.is_empty()
135                } else {
136                    // is not selected cat, just search, settings will be ignored
137                    s.iter().any(|s| match s.search_index(#{clean_search}) {
138                        Some(i) => {
139                            if i < top_match.0 {
140                                top_match = (i, s.key().clone());
141                            }
142                            true
143                        }
144                        None => false,
145                    })
146                }
147            });
148            let mut r = SettingsEditorState {
149                clean_search: #{clean_search}.clone(),
150                categories: r.iter().map(|(c, _)| c.clone()).collect(),
151                selected_cat: actual_cat.unwrap_or_else(|| Category::unknown(#{sel_cat}.clone())),
152                selected_settings: r
153                    .into_iter()
154                    .find_map(|(c, s)| if c.id() == #{sel_cat} { Some(s) } else { None })
155                    .unwrap_or_default(),
156                top_match: top_match.1,
157            };
158            SETTINGS.sort_categories(&mut r.categories);
159            SETTINGS.sort_settings(&mut r.selected_settings);
160            Some(r)
161        }
162    };
163
164    // select first category when previous selection is removed
165    let sel = SETTINGS.editor_selected_category().current_context();
166    let wk_sel_cat = sel.downgrade();
167    fn correct_sel(options: &[Category], sel: &Var<CategoryId>) {
168        if sel.with(|s| !options.iter().any(|c| c.id() == s))
169            && let Some(first) = options.first()
170        {
171            sel.set(first.id().clone());
172        }
173    }
174    r.hook(move |r| {
175        if let Some(sel) = wk_sel_cat.upgrade() {
176            correct_sel(&r.value().as_ref().unwrap().categories, &sel);
177            true
178        } else {
179            false
180        }
181    })
182    .perm();
183    r.with(|r| {
184        correct_sel(&r.as_ref().unwrap().categories, &sel);
185    });
186
187    r
188}
189
190fn settings_view_fn() -> UiNode {
191    let search = SETTINGS_SEARCH_FN_VAR.get()(SettingsSearchArgs {});
192
193    let editor_state = SETTINGS.editor_state().current_context();
194
195    let categories = editor_state
196        .map(|r| r.as_ref().unwrap().categories.clone())
197        .present(wgt_fn!(|categories: Vec<Category>| {
198            let cat_fn = CATEGORY_ITEM_FN_VAR.get();
199            let categories: UiVec = categories
200                .into_iter()
201                .enumerate()
202                .map(|(i, c)| cat_fn(CategoryItemArgs { index: i, category: c }))
203                .collect();
204
205            CATEGORIES_LIST_FN_VAR.get()(CategoriesListArgs { items: categories })
206        }));
207
208    let settings = editor_state.present(wgt_fn!(|state: Option<SettingsEditorState>| {
209        let SettingsEditorState {
210            selected_cat,
211            selected_settings,
212            ..
213        } = state.unwrap();
214        let setting_fn = SETTING_FN_VAR.get();
215
216        let settings: UiVec = selected_settings
217            .into_iter()
218            .enumerate()
219            .map(|(i, s)| {
220                let editor = s.editor();
221                setting_fn(SettingArgs {
222                    index: i,
223                    setting: s.clone(),
224                    editor,
225                })
226            })
227            .collect();
228
229        let header = CATEGORY_HEADER_FN_VAR.get()(CategoryHeaderArgs { category: selected_cat });
230
231        SETTINGS_FN_VAR.get()(SettingsArgs { header, items: settings })
232    }));
233
234    PANEL_FN_VAR.get()(PanelArgs {
235        search,
236        categories,
237        settings,
238    })
239}
240
241/// Save and restore settings search and selected category.
242///
243/// This property is enabled by default in the `SettingsEditor!` widget, without a key. Note that without a config key
244/// this feature only actually enables if the settings widget ID has a name.
245#[property(CONTEXT, widget_impl(SettingsEditor))]
246pub fn save_state(child: impl IntoUiNode, enabled: impl IntoValue<SaveState>) -> UiNode {
247    #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
248    struct SettingsEditorCfg {
249        search: Txt,
250        selected_category: CategoryId,
251    }
252    save_state_node::<SettingsEditorCfg>(
253        child,
254        enabled,
255        |cfg| {
256            let search = SETTINGS.editor_search();
257            let cat = SETTINGS.editor_selected_category();
258            WIDGET.sub_var(&search).sub_var(&cat);
259            if let Some(c) = cfg {
260                search.set(c.search);
261                cat.set(c.selected_category);
262            }
263        },
264        |required| {
265            let search = SETTINGS.editor_search();
266            let cat = SETTINGS.editor_selected_category();
267            if required || search.is_new() || cat.is_new() {
268                Some(SettingsEditorCfg {
269                    search: search.get(),
270                    selected_category: cat.get(),
271                })
272            } else {
273                None
274            }
275        },
276    )
277}
278
279/// Intrinsic SETTINGS_CMD handler.
280fn command_handler(child: impl IntoUiNode) -> UiNode {
281    let mut _handle = CommandHandle::dummy();
282    match_node(child, move |c, op| match op {
283        UiNodeOp::Init => {
284            _handle = SETTINGS_CMD.scoped(WIDGET.id()).subscribe(true);
285        }
286        UiNodeOp::Deinit => {
287            _handle = CommandHandle::dummy();
288        }
289        UiNodeOp::Event { update } => {
290            c.event(update);
291
292            if let Some(args) = SETTINGS_CMD.scoped(WIDGET.id()).on_unhandled(update) {
293                args.propagation().stop();
294
295                if let Some(id) = args.param::<CategoryId>() {
296                    if SETTINGS
297                        .editor_state()
298                        .with(|s| s.as_ref().unwrap().categories.iter().any(|c| c.id() == id))
299                    {
300                        SETTINGS.editor_selected_category().set(id.clone());
301                    }
302                } else if let Some(key) = args.param::<Txt>() {
303                    let search = if SETTINGS.any(|k, _| k == key) {
304                        formatx!("@key:{key}")
305                    } else {
306                        key.clone()
307                    };
308                    SETTINGS.editor_search().set(search);
309                } else if args.param.is_none() && !FOCUS.is_focus_within(WIDGET.id()).get() {
310                    // focus top match
311                    let s = Some(SETTINGS.editor_state().with(|s| s.as_ref().unwrap().top_match.clone()));
312                    let info = WIDGET.info();
313                    if let Some(w) = info.descendants().find(|w| w.setting_key() == s) {
314                        FOCUS.focus_widget_or_enter(w.id(), false, false);
315                    } else {
316                        FOCUS.focus_widget_or_enter(info.id(), false, false);
317                    }
318                }
319            }
320        }
321        _ => {}
322    })
323}
324
325/// Set a [`SETTINGS_CMD`] handler that shows the settings window.
326pub fn handle_settings_cmd() {
327    use zng_app::{event::AnyEventArgs as _, window::WINDOW};
328
329    SETTINGS_CMD
330        .on_event(
331            true,
332            async_hn!(|args| {
333                if args.propagation().is_stopped() || !SETTINGS.any(|_, _| true) {
334                    return;
335                }
336
337                args.propagation().stop();
338
339                let parent = WINDOWS.focused_window_id();
340
341                let new_window = WINDOWS.focus_or_open("zng-config-settings-default", async move {
342                    if let Some(p) = parent
343                        && let Ok(p) = WINDOWS.vars(p)
344                    {
345                        let v = WINDOW.vars();
346                        p.icon().set_bind(&v.icon()).perm();
347                    }
348
349                    Window! {
350                        title = l10n!("window.title", "{$app} - Settings", app = zng_env::about().app.clone());
351                        parent;
352                        child = SettingsEditor! {
353                            id = "zng-config-settings-default-editor";
354                        };
355                    }
356                });
357
358                if let Some(param) = &args.args.param {
359                    if let Some(w) = new_window {
360                        WINDOWS.wait_loaded(w.wait_rsp().await, true).await;
361                    }
362                    SETTINGS_CMD
363                        .scoped("zng-config-settings-default-editor")
364                        .notify_param(param.clone());
365                }
366            }),
367        )
368        .perm();
369}