zng_wgt_settings/
lib.rs

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