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 editor_state = SETTINGS.editor_state().current_context();
192
193    let has_results = editor_state.map(|r| !r.as_ref().unwrap().categories.is_empty());
194
195    let search = SETTINGS_SEARCH_FN_VAR.get()(SettingsSearchArgs {
196        has_results: has_results.clone(),
197    });
198
199    let categories = editor_state
200        .map(|r| r.as_ref().unwrap().categories.clone())
201        .present(wgt_fn!(|categories: Vec<Category>| {
202            let cat_fn = CATEGORY_ITEM_FN_VAR.get();
203            let categories: UiVec = categories
204                .into_iter()
205                .enumerate()
206                .map(|(i, c)| cat_fn(CategoryItemArgs { index: i, category: c }))
207                .collect();
208
209            CATEGORIES_LIST_FN_VAR.get()(CategoriesListArgs { items: categories })
210        }));
211
212    let settings = editor_state.present(wgt_fn!(|state: Option<SettingsEditorState>| {
213        let SettingsEditorState {
214            selected_cat,
215            selected_settings,
216            ..
217        } = state.unwrap();
218        let setting_fn = SETTING_FN_VAR.get();
219
220        let settings: UiVec = selected_settings
221            .into_iter()
222            .enumerate()
223            .map(|(i, s)| {
224                let editor = s.editor();
225                setting_fn(SettingArgs {
226                    index: i,
227                    setting: s.clone(),
228                    editor,
229                })
230            })
231            .collect();
232
233        let header = CATEGORY_HEADER_FN_VAR.get()(CategoryHeaderArgs { category: selected_cat });
234
235        SETTINGS_FN_VAR.get()(SettingsArgs { header, items: settings })
236    }));
237
238    PANEL_FN_VAR.get()(PanelArgs {
239        search,
240        categories,
241        settings,
242        has_results,
243    })
244}
245
246/// Save and restore settings search and selected category.
247///
248/// This property is enabled by default in the `SettingsEditor!` widget, without a key. Note that without a config key
249/// this feature only actually enables if the settings widget ID has a name.
250#[property(CONTEXT, widget_impl(SettingsEditor))]
251pub fn save_state(child: impl IntoUiNode, enabled: impl IntoValue<SaveState>) -> UiNode {
252    #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
253    struct SettingsEditorCfg {
254        search: Txt,
255        selected_category: CategoryId,
256    }
257    save_state_node::<SettingsEditorCfg>(
258        child,
259        enabled,
260        |cfg| {
261            let search = SETTINGS.editor_search();
262            let cat = SETTINGS.editor_selected_category();
263            WIDGET.sub_var(&search).sub_var(&cat);
264            if let Some(c) = cfg {
265                search.set(c.search);
266                cat.set(c.selected_category);
267            }
268        },
269        |required| {
270            let search = SETTINGS.editor_search();
271            let cat = SETTINGS.editor_selected_category();
272            if required || search.is_new() || cat.is_new() {
273                Some(SettingsEditorCfg {
274                    search: search.get(),
275                    selected_category: cat.get(),
276                })
277            } else {
278                None
279            }
280        },
281    )
282}
283
284/// Intrinsic SETTINGS_CMD handler.
285fn command_handler(child: impl IntoUiNode) -> UiNode {
286    let mut _handle = CommandHandle::dummy();
287    match_node(child, move |c, op| match op {
288        UiNodeOp::Init => {
289            _handle = SETTINGS_CMD.scoped(WIDGET.id()).subscribe(true);
290        }
291        UiNodeOp::Deinit => {
292            _handle = CommandHandle::dummy();
293        }
294        UiNodeOp::Event { update } => {
295            c.event(update);
296
297            if let Some(args) = SETTINGS_CMD.scoped(WIDGET.id()).on_unhandled(update) {
298                args.propagation().stop();
299
300                if let Some(id) = args.param::<CategoryId>() {
301                    if SETTINGS
302                        .editor_state()
303                        .with(|s| s.as_ref().unwrap().categories.iter().any(|c| c.id() == id))
304                    {
305                        SETTINGS.editor_selected_category().set(id.clone());
306                    }
307                } else if let Some(key) = args.param::<Txt>() {
308                    let search = if SETTINGS.any(|k, _| k == key) {
309                        formatx!("@key:{key}")
310                    } else {
311                        key.clone()
312                    };
313                    SETTINGS.editor_search().set(search);
314                } else if args.param.is_none() && !FOCUS.is_focus_within(WIDGET.id()).get() {
315                    // focus top match
316                    let s = Some(SETTINGS.editor_state().with(|s| s.as_ref().unwrap().top_match.clone()));
317                    let info = WIDGET.info();
318                    if let Some(w) = info.descendants().find(|w| w.setting_key() == s) {
319                        FOCUS.focus_widget_or_enter(w.id(), false, false);
320                    } else {
321                        FOCUS.focus_widget_or_enter(info.id(), false, false);
322                    }
323                }
324            }
325        }
326        _ => {}
327    })
328}
329
330/// Set a [`SETTINGS_CMD`] handler that shows the settings window.
331pub fn handle_settings_cmd() {
332    use zng_app::{event::AnyEventArgs as _, window::WINDOW};
333
334    SETTINGS_CMD
335        .on_event(
336            true,
337            async_hn!(|args| {
338                if args.propagation().is_stopped() || !SETTINGS.any(|_, _| true) {
339                    return;
340                }
341
342                args.propagation().stop();
343
344                let parent = WINDOWS.focused_window_id();
345
346                let new_window = WINDOWS.focus_or_open("zng-config-settings-default", async move {
347                    if let Some(p) = parent
348                        && let Ok(p) = WINDOWS.vars(p)
349                    {
350                        let v = WINDOW.vars();
351                        p.icon().set_bind(&v.icon()).perm();
352                    }
353
354                    Window! {
355                        title = l10n!("window.title", "{$app} - Settings", app = zng_env::about().app.clone());
356                        parent;
357                        child = SettingsEditor! {
358                            id = "zng-config-settings-default-editor";
359                        };
360                    }
361                });
362
363                if let Some(param) = &args.args.param {
364                    if let Some(w) = new_window {
365                        WINDOWS.wait_loaded(w.wait_rsp().await, true).await;
366                    }
367                    SETTINGS_CMD
368                        .scoped("zng-config-settings-default-editor")
369                        .notify_param(param.clone());
370                }
371            }),
372        )
373        .perm();
374}