Skip to main content

zng_wgt_wizard/
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//!
4//! Wizard widget.
5//!
6//! # Crate
7//!
8#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))]
9#![warn(unused_extern_crates)]
10#![warn(missing_docs)]
11
12zng_wgt::enable_widget_macros!();
13
14use zng_app::event::CommandArgs;
15use zng_ext_input::focus::FOCUS;
16use zng_var::MergeVarBuilder;
17use zng_view_api::keyboard::Key;
18use zng_wgt::prelude::*;
19use zng_wgt_input::{gesture, keyboard};
20use zng_wgt_style::style_fn;
21use zng_wgt_text_input::label;
22
23mod view_fn;
24
25pub use view_fn::*;
26
27/// Paginated widget that
28#[widget($crate::Wizard)]
29pub struct Wizard(WidgetBase);
30impl Wizard {
31    fn widget_intrinsic(&mut self) {
32        self.widget_builder().push_build_action(|wgt| {
33            let pages = wgt.capture_var_or_default(property_id!(pages));
34            wgt.set_child(node(pages));
35            wgt.push_intrinsic(NestGroup::CONTEXT, "state", |c| {
36                with_context_var(c, GET_TITLE_VAR, var(Txt::from_static("")))
37            });
38        });
39
40        widget_set! {
41            self;
42
43            // use mnemonic shortcuts
44            gesture::mnemonic_scope = true;
45            label::style_fn = style_fn!(|_| {
46                label::DefaultStyle! {
47                    label::mnemonic_underline = true;
48                    zng_wgt_text::underline = 1, LineStyle::Solid;
49                }
50            });
51            keyboard::on_key_up = hn!(|args| {
52                if let Key::Char(c) = args.key
53                    && c.is_alphanumeric()
54                    && !FOCUS.is_highlighting().get()
55                {
56                    // on unhandled alphanumeric press highlight focus to enable mnemonic keys
57                    FOCUS.highlight();
58                    args.propagation.stop();
59                }
60            });
61        }
62    }
63}
64
65context_var! {
66    static GET_TITLE_VAR: Txt = Txt::from_static("");
67}
68
69/// Defines the wizard pages.
70///
71/// Pages are built on demand, the [`Page`] value defines [`wgt_fn!`] builders
72/// that are used by wizard when the page needs to be instantiated.
73#[property(CHILD, widget_impl(Wizard))]
74pub fn pages(wgt: &mut WidgetBuilding, pages: impl IntoVar<Vec<Page>>) {
75    let _ = pages;
76    wgt.expect_property_capture();
77}
78
79/// Get the current page title.
80#[property(CONTEXT, widget_impl(Wizard))]
81pub fn get_title(child: impl IntoUiNode, title: impl IntoVar<Txt>) -> UiNode {
82    bind_state(child, GET_TITLE_VAR, title)
83}
84
85/// Represents a page builder for [`Wizard!`].
86///
87/// The widgets defined here must represent only the content that is unique for each page,
88/// the wizard widget has properties that define the wizard parts, for example, if the side
89/// has an image that is the same for all pages it is defined in [`Wizard::side_fn`].
90///
91/// The builders care called in the parent [`Wizard!`] widget context.
92///
93/// [`Wizard!`]: struct@Wizard
94#[non_exhaustive]
95#[derive(Clone, Debug, PartialEq)]
96pub struct Page {
97    /// Page title.
98    ///
99    /// The default `header` presents this for the selected page.
100    pub title: VarEq<Txt>,
101
102    /// Page info.
103    ///
104    /// This is a short explanation about the page. Supports basic markdown span formatting.
105    ///
106    /// The default `header` presents this for the selected page.
107    pub info: VarEq<Txt>,
108
109    /// Page header content.
110    ///
111    /// Presents the `title`, `info` and any other custom header detail
112    /// when the page is selected.
113    ///
114    /// The header of each page is wrapped by [`Wizard::header_fn`] to form the full header.
115    ///
116    /// If this builds [`UiNode::nil`] the header panel **is collapsed** for this page.
117    ///
118    /// Is [`default_page_header`] by default.
119    pub header: WidgetFn<PageArgs>,
120    /// Page side panel content.
121    ///
122    /// The side content of each page is wrapped by [`Wizard::side_fn`] to form the full side panel.
123    ///
124    /// If this is a list node the wizard side builder will generate a layout panel for the items.
125    ///
126    /// If this builds [`UiNode::nil`] the side panel **is collapsed** for this page. An empty list node
127    /// signals the side builder that it should be visible without page content.
128    ///
129    /// Is [`default_page_side`] by default.
130    pub side: WidgetFn<PageArgs>,
131    /// Page main content.
132    ///
133    /// The main content of each page is wrapped by [`Wizard::content_fn`] to form the full content panel.
134    pub content: WidgetFn<PageArgs>,
135    /// Page footer content.
136    ///
137    /// The footer of each page is wrapped by [`Wizard::footer_fn`] to form the full footer panel.
138    ///
139    /// If this is a list node the wizard footer builder will generate a layout panel for the items.
140    ///
141    /// Is [`default_page_footer`] by default.
142    pub footer: WidgetFn<PageArgs>,
143
144    /// When `true` the page is skipped over.
145    ///
146    /// Is `false` by default.
147    pub skip: VarEq<bool>,
148
149    /// When is not the first page controls the wizard `BACK_CMD` handle.
150    ///
151    /// Is `true` by default.
152    pub can_back: VarEq<bool>,
153
154    /// When is not the last page controls the wizard `NEXT_CMD` handle.
155    pub can_next: VarEq<bool>,
156}
157
158impl Page {
159    /// New basic page.
160    pub fn new(title: impl IntoVar<Txt>, info: impl IntoVar<Txt>, content: WidgetFn<PageArgs>) -> Self {
161        Self {
162            title: VarEq(title.into_var()),
163            info: VarEq(info.into_var()),
164            header: WidgetFn::new(default_page_header),
165            side: WidgetFn::new(default_page_side),
166            content,
167            footer: WidgetFn::new(default_page_footer),
168            skip: VarEq(var(false)),
169            can_back: VarEq(var(true)),
170            can_next: VarEq(var(true)),
171        }
172    }
173}
174/// Arguments for [`Page`] builders.
175#[non_exhaustive]
176#[derive(Clone)]
177pub struct PageArgs {
178    /// Page index on the pages list.
179    pub index: usize,
180    /// Count of pages on the list.
181    pub pages_len: usize,
182    /// The [`Page::title`] var.
183    pub title: Var<Txt>,
184    /// The [`Page::info`] var.
185    pub info: Var<Txt>,
186    /// The [`Page::can_back`] var.
187    pub can_back: Var<bool>,
188    /// The [`Page::can_next`] var.
189    pub can_next: Var<bool>,
190}
191impl PageArgs {
192    /// Is first page on the list.
193    pub fn is_first(&self) -> bool {
194        self.index == 0
195    }
196
197    /// Is last page on the list.
198    pub fn is_last(&self) -> bool {
199        self.index == self.pages_len.saturating_sub(1)
200    }
201
202    /// Get `WIDGET.id()`.
203    pub fn wizard_id(&self) -> WidgetId {
204        WIDGET.id()
205    }
206}
207
208command! {
209    /// Return to previous page.
210    pub static BACK_CMD {
211        l10n!: true,
212        name: "Back",
213    };
214
215    /// Advance to next page.
216    pub static NEXT_CMD {
217        l10n!: true,
218        name: "Next",
219    };
220
221    /// Cancel wizard operation.
222    pub static CANCEL_CMD {
223        l10n!: true,
224        name: "Cancel",
225    };
226
227    /// Finish wizard operation.
228    ///
229    /// This command also represents the transition from a pages set to another, for example,
230    /// a setup wizard starts with only the config pages, the [`finish_cmd_name`]
231    /// is set to "Install", on finish the pages are swapped to the progress and results page.
232    ///
233    /// [`finish_cmd_name`]: fn@finish_cmd_name
234    pub static FINISH_CMD {
235        l10n!: true,
236        name: "Finish",
237    };
238}
239command_property! {
240    /// Wizard cancel requested.
241    #[property(EVENT, widget_impl(Wizard))]
242    pub fn on_cancel<on_pre_cancel, can_cancel>(child: impl IntoUiNode, handler: Handler<CommandArgs>) -> UiNode {
243        CANCEL_CMD
244    }
245
246    /// Wizard finish requested.
247    #[property(EVENT, widget_impl(Wizard))]
248    pub fn on_finish<on_pre_finish, can_finish>(child: impl IntoUiNode, handler: Handler<CommandArgs>) -> UiNode {
249        FINISH_CMD
250    }
251}
252
253/// Set the name for the [`FINISH_CMD`] scoped on this widget.
254#[property(CONTEXT, widget_impl(Wizard))]
255pub fn finish_cmd_name(child: impl IntoUiNode, name: impl IntoVar<Txt>) -> UiNode {
256    let name = name.into_var();
257    match_node(child, move |_, op| {
258        if let UiNodeOp::Init = op {
259            let finish_name = FINISH_CMD.scoped(WIDGET.id()).name();
260            let h = name.set_bind(&finish_name);
261            WIDGET.push_var_handle(h);
262        }
263    })
264}
265
266fn node(pages: Var<Vec<Page>>) -> UiNode {
267    let mut cmds = [CommandHandle::dummy(), CommandHandle::dummy()];
268    let mut selected_page = 0usize;
269    let mut get_title = VarHandle::dummy();
270    match_node(UiNode::nil(), move |c, op| match op {
271        UiNodeOp::Init => {
272            WIDGET
273                .sub_var(&pages)
274                .sub_var(&PANEL_FN_VAR)
275                .sub_var(&HEADER_FN_VAR)
276                .sub_var(&HEADER_BACKGROUND_FN_VAR)
277                .sub_var(&SIDE_FN_VAR)
278                .sub_var(&SIDE_BACKGROUND_FN_VAR)
279                .sub_var(&SIDE_EXTRA_FN_VAR)
280                .sub_var(&CONTENT_FN_VAR)
281                .sub_var(&FOOTER_FN_VAR)
282                .sub_var(&FOOTER_EXTRA_FN_VAR);
283            pages.with(|p| {
284                if !p.is_empty() {
285                    cmds = subscribe(0, p);
286                    *c.node() = build(0, p);
287                    get_title = p[0].title.set_bind(&GET_TITLE_VAR);
288                }
289            });
290        }
291        UiNodeOp::Deinit => {
292            c.deinit();
293            *c.node() = UiNode::nil();
294            cmds = [CommandHandle::dummy(), CommandHandle::dummy()];
295            selected_page = 0;
296            get_title = VarHandle::dummy();
297        }
298        UiNodeOp::Update { updates } => {
299            c.update(updates);
300
301            let mut rebuild = false;
302            let id = WIDGET.id();
303            BACK_CMD.scoped(id).each_update(true, false, |args| {
304                while selected_page > 0 {
305                    selected_page -= 1;
306                    if !pages.with(|p| p[selected_page].skip.get()) {
307                        rebuild = true;
308                        break;
309                    }
310                }
311                args.propagation.stop();
312            });
313            NEXT_CMD.scoped(id).each_update(true, false, |args| {
314                let last = pages.with(|p| p.len()).saturating_sub(1);
315                while selected_page < last {
316                    selected_page += 1;
317                    if !pages.with(|p| p[selected_page].skip.get()) {
318                        rebuild = true;
319                        break;
320                    }
321                }
322                args.propagation.stop();
323            });
324
325            if pages.is_new() {
326                selected_page = 0;
327                rebuild = true;
328            } else if PANEL_FN_VAR.is_new()
329                || HEADER_FN_VAR.is_new()
330                || HEADER_BACKGROUND_FN_VAR.is_new()
331                || SIDE_FN_VAR.is_new()
332                || SIDE_BACKGROUND_FN_VAR.is_new()
333                || SIDE_EXTRA_FN_VAR.is_new()
334                || CONTENT_FN_VAR.is_new()
335                || FOOTER_FN_VAR.is_new()
336                || FOOTER_EXTRA_FN_VAR.is_new()
337            {
338                rebuild = true;
339            }
340
341            if rebuild {
342                c.deinit();
343                pages.with(|p| {
344                    if !p.is_empty() {
345                        cmds = subscribe(selected_page, p);
346                        *c.node() = build(selected_page, p);
347                        get_title = p[selected_page].title.set_bind(&GET_TITLE_VAR);
348                        c.init();
349                    } else {
350                        cmds = [CommandHandle::dummy(), CommandHandle::dummy()];
351                        *c.node() = UiNode::nil();
352                        get_title = VarHandle::dummy();
353                    }
354                });
355                WIDGET.update_info().layout().render();
356            }
357        }
358        _ => {}
359    })
360}
361
362fn subscribe(index: usize, pages: &[Page]) -> [CommandHandle; 2] {
363    let id = WIDGET.id();
364    let cmds = [BACK_CMD.scoped(id).subscribe(false), NEXT_CMD.scoped(id).subscribe(false)];
365
366    let mut skips = MergeVarBuilder::new();
367    for p in pages {
368        skips.push(p.skip.0.clone());
369    }
370    let can_dos = skips.build(move |skips| {
371        let mut can_back = false;
372        for i in 0..index {
373            can_back = !skips.get(i);
374            if can_back {
375                break;
376            }
377        }
378        let mut can_next = false;
379        for i in index + 1..skips.len() {
380            can_next = !skips.get(i);
381            if can_next {
382                break;
383            }
384        }
385        [can_back, can_next]
386    });
387
388    can_dos.set_bind_map(cmds[0].enabled(), |[b, _]| *b).perm();
389    can_dos.set_bind_map(cmds[1].enabled(), |[_, n]| *n).perm();
390    cmds[0].enabled().hold(can_dos).perm();
391
392    cmds
393}
394fn build(index: usize, pages: &[Page]) -> UiNode {
395    let page = &pages[index];
396    let args = PageArgs {
397        index,
398        pages_len: pages.len(),
399        title: page.title.0.clone(),
400        info: page.info.0.clone(),
401        can_back: page.can_back.0.clone(),
402        can_next: page.can_next.0.clone(),
403    };
404    let header = (page.header)(args.clone());
405    let side = (page.side)(args.clone());
406    let content = (page.content)(args.clone());
407    let footer = (page.footer)(args.clone());
408
409    let header = if header.is_nil() {
410        header
411    } else {
412        let background = HEADER_BACKGROUND_FN_VAR.get()(());
413        HEADER_FN_VAR.get()(HeaderFnArgs {
414            header,
415            background,
416            index,
417            pages_len: pages.len(),
418            titles: pages.iter().map(|p| p.title.0.clone()).collect(),
419            skips: pages.iter().map(|p| p.skip.0.clone()).collect(),
420        })
421    };
422    let side = if side.is_nil() {
423        side
424    } else {
425        let background = SIDE_BACKGROUND_FN_VAR.get()(());
426        let side_extra = SIDE_EXTRA_FN_VAR.get()(args.clone());
427        SIDE_FN_VAR.get()(SideFnArgs {
428            side,
429            background,
430            side_extra,
431            index,
432            pages_len: pages.len(),
433        })
434    };
435    let content = CONTENT_FN_VAR.get()(ContentFnArgs {
436        content,
437        index,
438        pages_len: pages.len(),
439    });
440    let footer_extra = FOOTER_EXTRA_FN_VAR.get()(args);
441    let footer = FOOTER_FN_VAR.get()(FooterFnArgs {
442        footer,
443        footer_extra,
444        index,
445        pages_len: pages.len(),
446    });
447
448    PANEL_FN_VAR.get()(PanelFnArgs {
449        header,
450        side,
451        content,
452        footer,
453    })
454}