Skip to main content

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
86fn editor_state() -> Var<Option<SettingsEditorState>> {
87    // avoids rebuilds for ignored search changes
88    let clean_search = SETTINGS.editor_search().current_context().map(|s| {
89        let s = s.trim();
90        if !s.starts_with('@') {
91            s.to_lowercase().into()
92        } else {
93            Txt::from_str(s)
94        }
95    });
96
97    let sel_cat = SETTINGS.editor_selected_category().current_context();
98    let r = expr_var! {
99        if #{clean_search}.is_empty() {
100            // no search, does not need to load settings of other categories
101            let (cat, settings) = SETTINGS
102                .get(|_, cat| cat == #{sel_cat}, true)
103                .pop()
104                .unwrap_or_else(|| (Category::unknown(#{sel_cat}.clone()), vec![]));
105            Some(SettingsEditorState {
106                clean_search: #{clean_search}.clone(),
107                categories: SETTINGS.categories(|_| true, false, true),
108                selected_cat: cat,
109                top_match: settings.first().map(|s| s.key().clone()).unwrap_or_default(),
110                selected_settings: settings,
111            })
112        } else {
113            // has search, just load everything
114            let mut r = SETTINGS.get(|_, _| true, false);
115
116            // apply search filter, get best match key (top_match), actual selected_cat.
117            let mut top_match = (usize::MAX, Txt::from(""));
118            let mut actual_cat = None;
119            r.retain_mut(|(c, s)| {
120                if c.id() == #{sel_cat} {
121                    // is selected cat
122                    actual_cat = Some(c.clone());
123                    // actually filter settings
124                    s.retain(|s| match s.search_index(#{clean_search}) {
125                        Some(i) => {
126                            if i < top_match.0 {
127                                top_match = (i, s.key().clone());
128                            }
129                            true
130                        }
131                        None => false,
132                    });
133                    !s.is_empty()
134                } else {
135                    // is not selected cat, just search, settings will be ignored
136                    s.iter().any(|s| match s.search_index(#{clean_search}) {
137                        Some(i) => {
138                            if i < top_match.0 {
139                                top_match = (i, s.key().clone());
140                            }
141                            true
142                        }
143                        None => false,
144                    })
145                }
146            });
147            let mut r = SettingsEditorState {
148                clean_search: #{clean_search}.clone(),
149                categories: r.iter().map(|(c, _)| c.clone()).collect(),
150                selected_cat: actual_cat.unwrap_or_else(|| Category::unknown(#{sel_cat}.clone())),
151                selected_settings: r
152                    .into_iter()
153                    .find_map(|(c, s)| if c.id() == #{sel_cat} { Some(s) } else { None })
154                    .unwrap_or_default(),
155                top_match: top_match.1,
156            };
157            SETTINGS.sort_categories(&mut r.categories);
158            SETTINGS.sort_settings(&mut r.selected_settings);
159            Some(r)
160        }
161    };
162
163    // select first category when previous selection is removed
164    let sel = SETTINGS.editor_selected_category().current_context();
165    let wk_sel_cat = sel.downgrade();
166    fn correct_sel(options: &[Category], sel: &Var<CategoryId>) {
167        if sel.with(|s| !options.iter().any(|c| c.id() == s))
168            && let Some(first) = options.first()
169        {
170            sel.set(first.id().clone());
171        }
172    }
173    r.hook(move |r| {
174        if let Some(sel) = wk_sel_cat.upgrade() {
175            correct_sel(&r.value().as_ref().unwrap().categories, &sel);
176            true
177        } else {
178            false
179        }
180    })
181    .perm();
182    r.with(|r| {
183        correct_sel(&r.as_ref().unwrap().categories, &sel);
184    });
185
186    r
187}
188
189fn settings_view_fn() -> UiNode {
190    let editor_state = SETTINGS.editor_state().current_context();
191
192    let has_results = editor_state.map(|r| !r.as_ref().unwrap().categories.is_empty());
193
194    let search = SETTINGS_SEARCH_FN_VAR.get()(SettingsSearchArgs {
195        has_results: has_results.clone(),
196    });
197
198    let categories = editor_state
199        .map(|r| r.as_ref().unwrap().categories.clone())
200        .present(wgt_fn!(|categories: Vec<Category>| {
201            let cat_fn = CATEGORY_ITEM_FN_VAR.get();
202            let categories: UiVec = categories
203                .into_iter()
204                .enumerate()
205                .map(|(i, c)| cat_fn(CategoryItemArgs { index: i, category: c }))
206                .collect();
207
208            CATEGORIES_LIST_FN_VAR.get()(CategoriesListArgs { items: categories })
209        }));
210
211    let settings = editor_state.present(wgt_fn!(|state: Option<SettingsEditorState>| {
212        let SettingsEditorState {
213            selected_cat,
214            selected_settings,
215            ..
216        } = state.unwrap();
217        let setting_fn = SETTING_FN_VAR.get();
218
219        let settings: UiVec = selected_settings
220            .into_iter()
221            .enumerate()
222            .map(|(i, s)| {
223                let editor = s.editor();
224                setting_fn(SettingArgs {
225                    index: i,
226                    setting: s.clone(),
227                    editor,
228                })
229            })
230            .collect();
231
232        let header = CATEGORY_HEADER_FN_VAR.get()(CategoryHeaderArgs { category: selected_cat });
233
234        SETTINGS_FN_VAR.get()(SettingsArgs { header, items: settings })
235    }));
236
237    PANEL_FN_VAR.get()(PanelArgs {
238        search,
239        categories,
240        settings,
241        has_results,
242    })
243}
244
245/// Save and restore settings search and selected category.
246///
247/// This property is enabled by default in the `SettingsEditor!` widget, without a key. Note that without a config key
248/// this feature only actually enables if the settings widget ID has a name.
249#[property(CONTEXT, widget_impl(SettingsEditor))]
250pub fn save_state(child: impl IntoUiNode, enabled: impl IntoValue<SaveState>) -> UiNode {
251    #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
252    struct SettingsEditorCfg {
253        search: Txt,
254        selected_category: CategoryId,
255    }
256    save_state_node::<SettingsEditorCfg>(
257        child,
258        enabled,
259        |cfg| {
260            let search = SETTINGS.editor_search();
261            let cat = SETTINGS.editor_selected_category();
262            WIDGET.sub_var(&search).sub_var(&cat);
263            if let Some(c) = cfg {
264                search.set(c.search);
265                cat.set(c.selected_category);
266            }
267        },
268        |required| {
269            let search = SETTINGS.editor_search();
270            let cat = SETTINGS.editor_selected_category();
271            if required || search.is_new() || cat.is_new() {
272                Some(SettingsEditorCfg {
273                    search: search.get(),
274                    selected_category: cat.get(),
275                })
276            } else {
277                None
278            }
279        },
280    )
281}
282
283/// Intrinsic SETTINGS_CMD handler.
284fn command_handler(child: impl IntoUiNode) -> UiNode {
285    let mut _handle = CommandHandle::dummy();
286    match_node(child, move |c, op| match op {
287        UiNodeOp::Init => {
288            _handle = SETTINGS_CMD.scoped(WIDGET.id()).subscribe(true);
289        }
290        UiNodeOp::Deinit => {
291            _handle = CommandHandle::dummy();
292        }
293        UiNodeOp::Update { updates } => {
294            c.update(updates);
295
296            SETTINGS_CMD.scoped(WIDGET.id()).each_update(true, false, |args| {
297                args.propagation.stop();
298
299                if let Some(id) = args.param::<CategoryId>() {
300                    if SETTINGS
301                        .editor_state()
302                        .with(|s| s.as_ref().unwrap().categories.iter().any(|c| c.id() == id))
303                    {
304                        SETTINGS.editor_selected_category().set(id.clone());
305                    }
306                } else if let Some(key) = args.param::<Txt>() {
307                    let search = if SETTINGS.any(|k, _| k == key) {
308                        formatx!("@key:{key}")
309                    } else {
310                        key.clone()
311                    };
312                    SETTINGS.editor_search().set(search);
313                } else if args.param.is_none() && !FOCUS.is_focus_within(WIDGET.id()).get() {
314                    // focus top match
315                    let s = Some(SETTINGS.editor_state().with(|s| s.as_ref().unwrap().top_match.clone()));
316                    let info = WIDGET.info();
317                    if let Some(w) = info.descendants().find(|w| w.setting_key() == s) {
318                        FOCUS.focus_widget_or_enter(w.id(), false, false);
319                    } else {
320                        FOCUS.focus_widget_or_enter(info.id(), false, false);
321                    }
322                }
323            });
324        }
325        _ => {}
326    })
327}
328
329/// Set a [`SETTINGS_CMD`] handler that shows the settings window.
330pub fn handle_settings_cmd() {
331    SETTINGS_CMD
332        .on_event(
333            true,
334            true,
335            false,
336            async_hn!(|args| {
337                if !SETTINGS.any(|_, _| true) {
338                    return;
339                }
340
341                args.propagation.stop();
342
343                let parent = FOCUS.focused().with(|p| p.as_ref().map(|t| t.window_id()));
344
345                let new_window = WINDOWS.focus_or_open("zng-config-settings-default", async move {
346                    if let Some(p) = parent
347                        && let Some(p) = WINDOWS.vars(p)
348                    {
349                        let v = WINDOW.vars();
350                        p.icon().set_bind(&v.icon()).perm();
351                    }
352
353                    Window! {
354                        title = l10n!("window.title", "{$app} - Settings", app = zng_env::about().app.clone());
355                        parent;
356                        child = SettingsEditor! {
357                            id = "zng-config-settings-default-editor";
358                        };
359                    }
360                });
361
362                if let Some(param) = &args.param {
363                    let w = new_window.wait_rsp().await;
364                    w.instance_state().wait_match(|s| s.is_loaded()).await;
365
366                    SETTINGS_CMD
367                        .scoped("zng-config-settings-default-editor")
368                        .notify_param(param.clone());
369                }
370            }),
371        )
372        .perm();
373}