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
136    /// If the `content` prefers to fully fill the content area.
137    ///
138    /// This is a hint for [`Wizard::content_fn`]. By default this is `false` and the content
139    /// is wrapped in a `Scroll!` with padding.
140    ///
141    /// Only set this to `true` if you want to remove all padding or if you want to scroll just
142    /// a part of the content with the rest filling the area.
143    pub content_fill: bool,
144
145    /// Page footer content.
146    ///
147    /// The footer of each page is wrapped by [`Wizard::footer_fn`] to form the full footer panel.
148    ///
149    /// If this is a list node the wizard footer builder will generate a layout panel for the items.
150    ///
151    /// Is [`default_page_footer`] by default.
152    pub footer: WidgetFn<PageArgs>,
153
154    /// When `true` the page is skipped over.
155    ///
156    /// Is `false` by default.
157    pub skip: VarEq<bool>,
158
159    /// When is not the first page controls the wizard `BACK_CMD` handle.
160    ///
161    /// Is `true` by default.
162    pub can_back: VarEq<bool>,
163
164    /// When is not the last page controls the wizard `NEXT_CMD` handle.
165    pub can_next: VarEq<bool>,
166}
167
168impl Page {
169    /// New basic page.
170    pub fn new(title: impl IntoVar<Txt>, info: impl IntoVar<Txt>, content: WidgetFn<PageArgs>) -> Self {
171        Self {
172            title: VarEq(title.into_var()),
173            info: VarEq(info.into_var()),
174            header: WidgetFn::new(default_page_header),
175            side: WidgetFn::new(default_page_side),
176            content,
177            content_fill: false,
178            footer: WidgetFn::new(default_page_footer),
179            skip: VarEq(var(false)),
180            can_back: VarEq(var(true)),
181            can_next: VarEq(var(true)),
182        }
183    }
184}
185/// Arguments for [`Page`] builders.
186#[non_exhaustive]
187#[derive(Clone)]
188pub struct PageArgs {
189    /// Page index on the pages list.
190    pub index: usize,
191    /// Count of pages on the list.
192    pub pages_len: usize,
193    /// The [`Page::title`] var.
194    pub title: Var<Txt>,
195    /// The [`Page::info`] var.
196    pub info: Var<Txt>,
197    /// The [`Page::can_back`] var.
198    pub can_back: Var<bool>,
199    /// The [`Page::can_next`] var.
200    pub can_next: Var<bool>,
201}
202impl PageArgs {
203    /// Is first page on the list.
204    pub fn is_first(&self) -> bool {
205        self.index == 0
206    }
207
208    /// Is last page on the list.
209    pub fn is_last(&self) -> bool {
210        self.index == self.pages_len.saturating_sub(1)
211    }
212
213    /// Get `WIDGET.id()`.
214    pub fn wizard_id(&self) -> WidgetId {
215        WIDGET.id()
216    }
217}
218
219command! {
220    /// Return to previous page.
221    pub static BACK_CMD {
222        l10n!: true,
223        name: "Back",
224    };
225
226    /// Advance to next page.
227    pub static NEXT_CMD {
228        l10n!: true,
229        name: "Next",
230    };
231
232    /// Cancel wizard operation.
233    pub static CANCEL_CMD {
234        l10n!: true,
235        name: "Cancel",
236    };
237
238    /// Finish wizard operation.
239    ///
240    /// This command also represents the transition from a pages set to another, for example,
241    /// a setup wizard starts with only the config pages, the [`finish_cmd_name`]
242    /// is set to "Install", on finish the pages are swapped to the progress and results page.
243    ///
244    /// [`finish_cmd_name`]: fn@finish_cmd_name
245    pub static FINISH_CMD {
246        l10n!: true,
247        name: "Finish",
248    };
249}
250command_property! {
251    /// Wizard cancel requested.
252    #[property(EVENT, widget_impl(Wizard))]
253    pub fn on_cancel<on_pre_cancel, can_cancel>(child: impl IntoUiNode, handler: Handler<CommandArgs>) -> UiNode {
254        CANCEL_CMD
255    }
256
257    /// Wizard finish requested.
258    #[property(EVENT, widget_impl(Wizard))]
259    pub fn on_finish<on_pre_finish, can_finish>(child: impl IntoUiNode, handler: Handler<CommandArgs>) -> UiNode {
260        FINISH_CMD
261    }
262}
263
264/// Set the name for the [`FINISH_CMD`] scoped on this widget.
265#[property(CONTEXT, widget_impl(Wizard))]
266pub fn finish_cmd_name(child: impl IntoUiNode, name: impl IntoVar<Txt>) -> UiNode {
267    let name = name.into_var();
268    match_node(child, move |_, op| {
269        if let UiNodeOp::Init = op {
270            let finish_name = FINISH_CMD.scoped(WIDGET.id()).name();
271            let h = name.set_bind(&finish_name);
272            WIDGET.push_var_handle(h);
273        }
274    })
275}
276
277fn node(pages: Var<Vec<Page>>) -> UiNode {
278    let mut cmds = [CommandHandle::dummy(), CommandHandle::dummy()];
279    let mut selected_page = 0usize;
280    let mut get_title = VarHandle::dummy();
281    match_node(UiNode::nil(), move |c, op| match op {
282        UiNodeOp::Init => {
283            WIDGET
284                .sub_var(&pages)
285                .sub_var(&PANEL_FN_VAR)
286                .sub_var(&HEADER_FN_VAR)
287                .sub_var(&HEADER_BACKGROUND_FN_VAR)
288                .sub_var(&SIDE_FN_VAR)
289                .sub_var(&SIDE_BACKGROUND_FN_VAR)
290                .sub_var(&SIDE_EXTRA_FN_VAR)
291                .sub_var(&CONTENT_FN_VAR)
292                .sub_var(&FOOTER_FN_VAR)
293                .sub_var(&FOOTER_EXTRA_FN_VAR);
294            pages.with(|p| {
295                if !p.is_empty() {
296                    cmds = subscribe(0, p);
297                    *c.node() = build(0, p);
298                    get_title = p[0].title.set_bind(&GET_TITLE_VAR);
299                }
300            });
301        }
302        UiNodeOp::Deinit => {
303            c.deinit();
304            *c.node() = UiNode::nil();
305            cmds = [CommandHandle::dummy(), CommandHandle::dummy()];
306            selected_page = 0;
307            get_title = VarHandle::dummy();
308        }
309        UiNodeOp::Update { updates } => {
310            c.update(updates);
311
312            let mut rebuild = false;
313            let id = WIDGET.id();
314            BACK_CMD.scoped(id).each_update(true, false, |args| {
315                while selected_page > 0 {
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            NEXT_CMD.scoped(id).each_update(true, false, |args| {
325                let last = pages.with(|p| p.len()).saturating_sub(1);
326                while selected_page < last {
327                    selected_page += 1;
328                    if !pages.with(|p| p[selected_page].skip.get()) {
329                        rebuild = true;
330                        break;
331                    }
332                }
333                args.propagation.stop();
334            });
335
336            if pages.is_new() {
337                selected_page = 0;
338                rebuild = true;
339            } else if PANEL_FN_VAR.is_new()
340                || HEADER_FN_VAR.is_new()
341                || HEADER_BACKGROUND_FN_VAR.is_new()
342                || SIDE_FN_VAR.is_new()
343                || SIDE_BACKGROUND_FN_VAR.is_new()
344                || SIDE_EXTRA_FN_VAR.is_new()
345                || CONTENT_FN_VAR.is_new()
346                || FOOTER_FN_VAR.is_new()
347                || FOOTER_EXTRA_FN_VAR.is_new()
348            {
349                rebuild = true;
350            }
351
352            if rebuild {
353                c.deinit();
354                pages.with(|p| {
355                    if !p.is_empty() {
356                        cmds = subscribe(selected_page, p);
357                        *c.node() = build(selected_page, p);
358                        get_title = p[selected_page].title.set_bind(&GET_TITLE_VAR);
359                        c.init();
360                    } else {
361                        cmds = [CommandHandle::dummy(), CommandHandle::dummy()];
362                        *c.node() = UiNode::nil();
363                        get_title = VarHandle::dummy();
364                    }
365                });
366                WIDGET.update_info().layout().render();
367            }
368        }
369        _ => {}
370    })
371}
372
373fn subscribe(index: usize, pages: &[Page]) -> [CommandHandle; 2] {
374    let id = WIDGET.id();
375    let cmds = [BACK_CMD.scoped(id).subscribe(false), NEXT_CMD.scoped(id).subscribe(false)];
376
377    let mut flags = MergeVarBuilder::new();
378    for p in pages {
379        flags.push(p.skip.0.clone());
380    }
381    flags.push(pages[index].can_back.0.clone());
382    flags.push(pages[index].can_next.0.clone());
383    let skips_len = pages.len();
384    let can_dos = flags.build(move |flags| {
385        let mut can_back = false;
386        if flags.get(skips_len) {
387            // if can_back.get()
388            for i in 0..index {
389                can_back = !flags.get(i);
390                if can_back {
391                    // if has prev pages that are not skip
392                    break;
393                }
394            }
395        }
396        let mut can_next = false;
397        if flags.get(skips_len + 1) {
398            // if can_next.get()
399            for i in index + 1..skips_len {
400                can_next = !flags.get(i);
401                if can_next {
402                    // if has next pages that are not skip
403                    break;
404                }
405            }
406        }
407        [can_back, can_next]
408    });
409
410    can_dos.set_bind_map(cmds[0].enabled(), |[b, _]| *b).perm();
411    can_dos.set_bind_map(cmds[1].enabled(), |[_, n]| *n).perm();
412    cmds[0].enabled().hold(can_dos).perm();
413
414    cmds
415}
416fn build(index: usize, pages: &[Page]) -> UiNode {
417    let page = &pages[index];
418    let args = PageArgs {
419        index,
420        pages_len: pages.len(),
421        title: page.title.0.clone(),
422        info: page.info.0.clone(),
423        can_back: page.can_back.0.clone(),
424        can_next: page.can_next.0.clone(),
425    };
426    let header = (page.header)(args.clone());
427    let side = (page.side)(args.clone());
428    let content = (page.content)(args.clone());
429    let footer = (page.footer)(args.clone());
430
431    let header = if header.is_nil() {
432        header
433    } else {
434        let background = HEADER_BACKGROUND_FN_VAR.get()(());
435        HEADER_FN_VAR.get()(HeaderFnArgs {
436            header,
437            background,
438            index,
439            pages_len: pages.len(),
440            titles: pages.iter().map(|p| p.title.0.clone()).collect(),
441            skips: pages.iter().map(|p| p.skip.0.clone()).collect(),
442        })
443    };
444    let side = if side.is_nil() {
445        side
446    } else {
447        let background = SIDE_BACKGROUND_FN_VAR.get()(());
448        let side_extra = SIDE_EXTRA_FN_VAR.get()(args.clone());
449        SIDE_FN_VAR.get()(SideFnArgs {
450            side,
451            background,
452            side_extra,
453            index,
454            pages_len: pages.len(),
455        })
456    };
457    let content = CONTENT_FN_VAR.get()(ContentFnArgs {
458        content,
459        content_fill: page.content_fill,
460        index,
461        pages_len: pages.len(),
462    });
463    let footer_extra = FOOTER_EXTRA_FN_VAR.get()(args);
464    let footer = FOOTER_FN_VAR.get()(FooterFnArgs {
465        footer,
466        footer_extra,
467        index,
468        pages_len: pages.len(),
469    });
470
471    PANEL_FN_VAR.get()(PanelFnArgs {
472        header,
473        side,
474        content,
475        footer,
476    })
477}