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 std::any::Any;
15
16use zng_app::event::CommandArgs;
17use zng_ext_input::focus::FOCUS;
18use zng_var::MergeVarBuilder;
19use zng_view_api::keyboard::Key;
20use zng_wgt::prelude::*;
21use zng_wgt_input::{gesture, keyboard};
22use zng_wgt_style::style_fn;
23use zng_wgt_text_input::label;
24
25mod view_fn;
26
27pub use view_fn::*;
28
29/// Paginated widget that
30#[widget($crate::Wizard)]
31pub struct Wizard(WidgetBase);
32impl Wizard {
33    fn widget_intrinsic(&mut self) {
34        self.widget_builder().push_build_action(|wgt| {
35            let pages = wgt.capture_var_or_default(property_id!(pages));
36            wgt.set_child(node(pages));
37            wgt.push_intrinsic(NestGroup::CONTEXT, "state", |c| {
38                let c = with_context_var(c, SELECTED_PAGE_VAR, var(Page::nil()));
39
40                zng_wgt::node::with_context_local_init(c, &WIZARD_CTX, || WizardCtx { id: WIDGET.id() })
41            });
42        });
43
44        widget_set! {
45            self;
46
47            // use mnemonic shortcuts
48            gesture::mnemonic_scope = true;
49            label::style_fn = style_fn!(|_| {
50                label::DefaultStyle! {
51                    label::mnemonic_underline = true;
52                    zng_wgt_text::underline = 1, LineStyle::Solid;
53                }
54            });
55            keyboard::on_key_up = hn!(|args| {
56                if let Key::Char(c) = args.key
57                    && c.is_alphanumeric()
58                    && !FOCUS.is_highlighting().get()
59                {
60                    // on unhandled alphanumeric press highlight focus to enable mnemonic keys
61                    FOCUS.highlight();
62                    args.propagation.stop();
63                }
64            });
65        }
66    }
67}
68
69context_var! {
70    static SELECTED_PAGE_VAR: Page = Page::nil();
71}
72context_local! {
73    static WIZARD_CTX: WizardCtx = WizardCtx::no_context();
74}
75struct WizardCtx {
76    id: WidgetId,
77}
78impl WizardCtx {
79    fn no_context() -> Self {
80        panic!("no `Wizard!` in context")
81    }
82}
83
84/// Defines the wizard pages.
85///
86/// Pages are built on demand, the [`Page`] value defines [`wgt_fn!`] builders
87/// that are used by wizard when the page needs to be instantiated.
88#[property(CHILD, widget_impl(Wizard))]
89pub fn pages(wgt: &mut WidgetBuilding, pages: impl IntoVar<Vec<Page>>) {
90    let _ = pages;
91    wgt.expect_property_capture();
92}
93
94/// Get the current page title.
95#[property(CONTEXT, widget_impl(Wizard))]
96pub fn get_title(child: impl IntoUiNode, title: impl IntoVar<Txt>) -> UiNode {
97    bind_state(child, SELECTED_PAGE_VAR.flat_map(|p| p.title.0.clone()), title)
98}
99
100/// Represents a page builder for [`Wizard!`].
101///
102/// The widgets defined here must represent only the content that is unique for each page,
103/// the wizard widget has properties that define the wizard parts, for example, if the side
104/// has an image that is the same for all pages it is defined in [`Wizard::side_fn`].
105///
106/// The builders care called in the parent [`Wizard!`] widget context.
107///
108/// [`Wizard!`]: struct@Wizard
109#[non_exhaustive]
110#[derive(Clone, Debug, PartialEq)]
111pub struct Page {
112    /// Page title.
113    ///
114    /// The default `header` presents this for the selected page.
115    pub title: VarEq<Txt>,
116
117    /// Page info.
118    ///
119    /// This is a short explanation about the page. Supports basic markdown span formatting.
120    ///
121    /// The default `header` presents this for the selected page.
122    pub info: VarEq<Txt>,
123
124    /// Page header content.
125    ///
126    /// Presents the `title`, `info` and any other custom header detail
127    /// when the page is selected.
128    ///
129    /// The header of each page is wrapped by [`Wizard::header_fn`] to form the full header.
130    ///
131    /// If this builds [`UiNode::nil`] the header panel **is collapsed** for this page.
132    ///
133    /// Is [`default_page_header`] by default.
134    pub header: WidgetFn<PageArgs>,
135    /// Page side panel content.
136    ///
137    /// The side content of each page is wrapped by [`Wizard::side_fn`] to form the full side panel.
138    ///
139    /// If this is a list node the wizard side builder will generate a layout panel for the items.
140    ///
141    /// If this builds [`UiNode::nil`] the side panel **is collapsed** for this page. An empty list node
142    /// signals the side builder that it should be visible without page content.
143    ///
144    /// Is [`default_page_side`] by default.
145    pub side: WidgetFn<PageArgs>,
146    /// Page main content.
147    ///
148    /// The main content of each page is wrapped by [`Wizard::content_fn`] to form the full content panel.
149    pub content: WidgetFn<PageArgs>,
150
151    /// If the `content` prefers to fully fill the content area.
152    ///
153    /// This is a hint for [`Wizard::content_fn`]. By default this is `false` and the content
154    /// is wrapped in a `Scroll!` with padding.
155    ///
156    /// Only set this to `true` if you want to remove all padding or if you want to scroll just
157    /// a part of the content with the rest filling the area.
158    pub content_fill: bool,
159
160    /// Page footer content.
161    ///
162    /// The footer of each page is wrapped by [`Wizard::footer_fn`] to form the full footer panel.
163    ///
164    /// If this is a list node the wizard footer builder will generate a layout panel for the items.
165    ///
166    /// Is [`default_page_footer`] by default.
167    pub footer: WidgetFn<PageArgs>,
168
169    /// When `true` the page is skipped over.
170    ///
171    /// Is `false` by default.
172    pub skip: VarEq<bool>,
173
174    /// When is not the first page controls the wizard `BACK_CMD` handle.
175    ///
176    /// Is `true` by default.
177    pub can_back: VarEq<bool>,
178
179    /// When is not the last page controls the wizard `NEXT_CMD` handle.
180    pub can_next: VarEq<bool>,
181}
182
183impl Page {
184    /// New basic page.
185    pub fn new(title: impl IntoVar<Txt>, info: impl IntoVar<Txt>, content: WidgetFn<PageArgs>) -> Self {
186        Self {
187            title: VarEq(title.into_var()),
188            info: VarEq(info.into_var()),
189            header: WidgetFn::new(default_page_header),
190            side: WidgetFn::new(default_page_side),
191            content,
192            content_fill: false,
193            footer: WidgetFn::new(default_page_footer),
194            skip: VarEq(var(false)),
195            can_back: VarEq(var(true)),
196            can_next: VarEq(var(true)),
197        }
198    }
199
200    /// New fully empty skip page.
201    pub fn nil() -> Self {
202        Self {
203            title: VarEq(const_var(Txt::from_static(""))),
204            info: VarEq(const_var(Txt::from_static(""))),
205            header: WidgetFn::nil(),
206            side: WidgetFn::nil(),
207            content: WidgetFn::nil(),
208            content_fill: false,
209            footer: WidgetFn::nil(),
210            skip: VarEq(const_var(true)),
211            can_back: VarEq(const_var(false)),
212            can_next: VarEq(const_var(false)),
213        }
214    }
215
216    /// Gets if is [`nil`].
217    ///
218    /// [`nil`]: Self::nil
219    pub fn is_nil(&self) -> bool {
220        self.header.is_nil()
221            && self.side.is_nil()
222            && self.content.is_nil()
223            && self.footer.is_nil()
224            && !self.content_fill
225            && self.title.0.capabilities().is_const()
226            && self.info.0.capabilities().is_const()
227            && self.skip.0.capabilities().is_const()
228            && self.can_back.0.capabilities().is_const()
229            && self.can_next.0.capabilities().is_const()
230            && self.title.0.with(|t| t.as_static_str() == Some(""))
231            && self.info.0.with(|t| t.as_static_str() == Some(""))
232            && self.skip.0.get()
233            && !self.can_back.0.get()
234            && !self.can_next.0.get()
235    }
236}
237/// Arguments for [`Page`] builders.
238#[non_exhaustive]
239#[derive(Clone)]
240pub struct PageArgs {
241    /// Page index on the pages list.
242    ///
243    /// Is `usize::MAX` if the page is a custom assign to [`WIZARD::selected_page`] that is not on the list.
244    pub index: usize,
245    /// Count of pages on the list.
246    pub pages_len: usize,
247    /// The [`Page::title`] var.
248    pub title: Var<Txt>,
249    /// The [`Page::info`] var.
250    pub info: Var<Txt>,
251    /// The [`Page::can_back`] var.
252    pub can_back: Var<bool>,
253    /// The [`Page::can_next`] var.
254    pub can_next: Var<bool>,
255}
256impl PageArgs {
257    /// Is first page on the list.
258    pub fn is_first(&self) -> bool {
259        self.index == 0
260    }
261
262    /// Is last page on the list.
263    pub fn is_last(&self) -> bool {
264        self.index == self.pages_len.saturating_sub(1)
265    }
266
267    /// Is custom page, not on the list.
268    pub fn is_custom(&self) -> bool {
269        self.index == usize::MAX
270    }
271
272    /// Get `WIDGET.id()`.
273    pub fn wizard_id(&self) -> WidgetId {
274        WIDGET.id()
275    }
276}
277
278command! {
279    /// Return to previous page.
280    pub static BACK_CMD {
281        l10n!: true,
282        name: "Back",
283    };
284
285    /// Advance to next page.
286    pub static NEXT_CMD {
287        l10n!: true,
288        name: "Next",
289    };
290
291    /// Cancel wizard config or operation.
292    pub static CANCEL_CMD {
293        l10n!: true,
294        name: "Cancel",
295    };
296
297    /// Begin wizard operation.
298    ///
299    /// This command represents the transition from config pages to a progress page.
300    /// The progress page is expected to automatically swap to a results page on completion.
301    ///
302    /// The [`begin_cmd_name`] can be used to change the command display name.
303    ///
304    /// [`begin_cmd_name`]: fn@begin_cmd_name
305    pub static BEGIN_CMD {
306        l10n!: true,
307        name: "Begin",
308    };
309
310    /// Finish wizard operation.
311    ///
312    /// This command represents the transition out of the wizard from the results page or the last
313    /// config page.
314    ///
315    /// If the wizard operation uses `BEGIN_CMD` the progress page will auto swap to
316    /// a results page, the results page offers the `FINISH_CMD`, to perhaps close the wizard window.
317    ///
318    /// The wizard operation can also simply represents a configuration that is instantly applied, in this
319    /// case the last config page can directly call `FINISH_CMD` to exit the wizard.
320    ///
321    /// The [`finish_cmd_name`] can be used to change the command display name.
322    ///
323    /// [`finish_cmd_name`]: fn@finish_cmd_name
324    pub static FINISH_CMD {
325        l10n!: true,
326        name: "Finish",
327    };
328}
329command_property! {
330    /// Wizard cancel requested.
331    #[property(EVENT, widget_impl(Wizard))]
332    pub fn on_cancel<on_pre_cancel, can_cancel>(child: impl IntoUiNode, handler: Handler<CommandArgs>) -> UiNode {
333        CANCEL_CMD
334    }
335
336    /// Wizard begin requested.
337    #[property(EVENT, widget_impl(Wizard))]
338    pub fn on_begin<on_pre_begin, can_begin>(child: impl IntoUiNode, handler: Handler<CommandArgs>) -> UiNode {
339        BEGIN_CMD
340    }
341
342    /// Wizard finish requested.
343    #[property(EVENT, widget_impl(Wizard))]
344    pub fn on_finish<on_pre_finish, can_finish>(child: impl IntoUiNode, handler: Handler<CommandArgs>) -> UiNode {
345        FINISH_CMD
346    }
347}
348
349/// Set the name for the [`BEGIN_CMD`] scoped on this widget.
350#[property(CONTEXT, widget_impl(Wizard))]
351pub fn begin_cmd_name(child: impl IntoUiNode, name: impl IntoVar<Txt>) -> UiNode {
352    let name = name.into_var();
353    match_node(child, move |_, op| {
354        if let UiNodeOp::Init = op {
355            let begin_name = BEGIN_CMD.scoped(WIDGET.id()).name();
356            let h = name.set_bind(&begin_name);
357            WIDGET.push_var_handle(h);
358        }
359    })
360}
361
362/// Set the name for the [`FINISH_CMD`] scoped on this widget.
363#[property(CONTEXT, widget_impl(Wizard))]
364pub fn finish_cmd_name(child: impl IntoUiNode, name: impl IntoVar<Txt>) -> UiNode {
365    let name = name.into_var();
366    match_node(child, move |_, op| {
367        if let UiNodeOp::Init = op {
368            let finish_name = FINISH_CMD.scoped(WIDGET.id()).name();
369            let h = name.set_bind(&finish_name);
370            WIDGET.push_var_handle(h);
371        }
372    })
373}
374
375fn node(pages: Var<Vec<Page>>) -> UiNode {
376    let mut cmds = [CommandHandle::dummy(), CommandHandle::dummy()];
377    let mut sel_pg_i = 0usize;
378    const CUSTOM_PAGE_I: usize = usize::MAX;
379    match_node(UiNode::nil(), move |c, op| match op {
380        UiNodeOp::Init => {
381            WIDGET
382                .sub_var(&pages)
383                .sub_var(&PANEL_FN_VAR)
384                .sub_var(&HEADER_FN_VAR)
385                .sub_var(&HEADER_BACKGROUND_FN_VAR)
386                .sub_var(&SIDE_FN_VAR)
387                .sub_var(&SIDE_BACKGROUND_FN_VAR)
388                .sub_var(&SIDE_EXTRA_FN_VAR)
389                .sub_var(&CONTENT_FN_VAR)
390                .sub_var(&FOOTER_FN_VAR)
391                .sub_var(&FOOTER_EXTRA_FN_VAR)
392                .sub_var(&SELECTED_PAGE_VAR);
393            pages.with(|p| {
394                if !p.is_empty() {
395                    sel_pg_i = 0;
396                    cmds = subscribe(0, p);
397                    *c.node() = build(0, p, None);
398                    SELECTED_PAGE_VAR.set(p[0].clone());
399                }
400            });
401        }
402        UiNodeOp::Deinit => {
403            c.deinit();
404            *c.node() = UiNode::nil();
405            cmds = [CommandHandle::dummy(), CommandHandle::dummy()];
406            SELECTED_PAGE_VAR.set(Page::nil());
407        }
408        UiNodeOp::Update { updates } => {
409            c.update(updates);
410
411            let mut rebuild = false;
412
413            if sel_pg_i != CUSTOM_PAGE_I {
414                // default BACK_CMD and NEXT_CMD
415
416                let scope = WIDGET.id();
417
418                BACK_CMD.scoped(scope).each_update(true, false, |args| {
419                    args.propagation.stop();
420
421                    // seek prev that is not skip
422                    pages.with(|pages| {
423                        for (i, pg) in pages[..sel_pg_i].iter().enumerate().rev() {
424                            if !pg.skip.get() {
425                                sel_pg_i = i;
426                                rebuild = true;
427                                break;
428                            }
429                        }
430                    });
431                });
432
433                NEXT_CMD.scoped(scope).each_update(true, false, |args| {
434                    args.propagation.stop();
435
436                    // seek next that is not skip
437                    pages.with(|pages| {
438                        for (i, pg) in pages.iter().enumerate().skip(sel_pg_i + 1) {
439                            if !pg.skip.get() {
440                                sel_pg_i = i;
441                                rebuild = true;
442                                break;
443                            }
444                        }
445                    });
446                });
447            }
448            if pages.is_new() {
449                sel_pg_i = 0;
450                rebuild = true;
451            } else if let Some(new) = SELECTED_PAGE_VAR.get_new() {
452                pages.with(|pages| {
453                    if let Some(i) = pages.iter().position(|p| p == &new) {
454                        if i != sel_pg_i {
455                            // custom assign, but page is known
456                            sel_pg_i = i;
457                            rebuild = true;
458                        }
459                        // else, not custom assign, already rebuilt
460                    } else {
461                        // custom assign
462                        sel_pg_i = CUSTOM_PAGE_I;
463                        rebuild = true;
464                    }
465                })
466            }
467            if !rebuild && PANEL_FN_VAR.is_new()
468                || HEADER_FN_VAR.is_new()
469                || HEADER_BACKGROUND_FN_VAR.is_new()
470                || SIDE_FN_VAR.is_new()
471                || SIDE_BACKGROUND_FN_VAR.is_new()
472                || SIDE_EXTRA_FN_VAR.is_new()
473                || CONTENT_FN_VAR.is_new()
474                || FOOTER_FN_VAR.is_new()
475                || FOOTER_EXTRA_FN_VAR.is_new()
476            {
477                rebuild = true;
478            }
479
480            if rebuild {
481                // replace child with new page instance
482                c.deinit();
483                WIDGET.update_info().layout().render();
484
485                pages.with(|pages| {
486                    if sel_pg_i < pages.len() {
487                        // is valid selection from pages
488
489                        SELECTED_PAGE_VAR.set(pages[sel_pg_i].clone());
490
491                        // subscribe back/next
492                        cmds = subscribe(sel_pg_i, pages);
493                        // build and init
494                        *c.node() = build(sel_pg_i, pages, None);
495                        c.init();
496                    } else {
497                        // custom page or empty pages
498
499                        // no default back/next support
500                        cmds = [CommandHandle::dummy(), CommandHandle::dummy()];
501
502                        if sel_pg_i == CUSTOM_PAGE_I {
503                            // valid custom selection
504                            SELECTED_PAGE_VAR.with(|pg| {
505                                *c.node() = build(CUSTOM_PAGE_I, pages, Some(pg));
506                            });
507                            c.init();
508                        } else {
509                            // empty pages
510                            if !pages.is_empty() {
511                                tracing::error!("invalid page selection, {} in {}", sel_pg_i, pages.len());
512                            }
513                            *c.node() = UiNode::nil();
514                            SELECTED_PAGE_VAR.set(Page::nil());
515                        }
516                    }
517                });
518            }
519        }
520        _ => {}
521    })
522}
523
524fn subscribe(index: usize, pages: &[Page]) -> [CommandHandle; 2] {
525    let id = WIDGET.id();
526    let cmds = [BACK_CMD.scoped(id).subscribe(false), NEXT_CMD.scoped(id).subscribe(false)];
527
528    let mut flags = MergeVarBuilder::new();
529    for p in pages {
530        flags.push(p.skip.0.clone());
531    }
532    flags.push(pages[index].can_back.0.clone());
533    flags.push(pages[index].can_next.0.clone());
534    let skips_len = pages.len();
535    let can_dos = flags.build(move |flags| {
536        let mut can_back = false;
537        if flags.get(skips_len) {
538            // if can_back.get()
539            for i in 0..index {
540                can_back = !flags.get(i);
541                if can_back {
542                    // if has prev pages that are not skip
543                    break;
544                }
545            }
546        }
547        let mut can_next = false;
548        if flags.get(skips_len + 1) {
549            // if can_next.get()
550            for i in index + 1..skips_len {
551                can_next = !flags.get(i);
552                if can_next {
553                    // if has next pages that are not skip
554                    break;
555                }
556            }
557        }
558        [can_back, can_next]
559    });
560
561    can_dos.set_bind_map(cmds[0].enabled(), |[b, _]| *b).perm();
562    can_dos.set_bind_map(cmds[1].enabled(), |[_, n]| *n).perm();
563    cmds[0].enabled().hold(can_dos).perm();
564
565    cmds
566}
567fn build(index: usize, pages: &[Page], custom_page: Option<&Page>) -> UiNode {
568    let page = custom_page.unwrap_or_else(|| &pages[index]);
569    let args = PageArgs {
570        index,
571        pages_len: pages.len(),
572        title: page.title.0.clone(),
573        info: page.info.0.clone(),
574        can_back: page.can_back.0.clone(),
575        can_next: page.can_next.0.clone(),
576    };
577    let header = (page.header)(args.clone());
578    let side = (page.side)(args.clone());
579    let content = (page.content)(args.clone());
580    let footer = (page.footer)(args.clone());
581
582    let header = if header.is_nil() {
583        header
584    } else {
585        let background = HEADER_BACKGROUND_FN_VAR.get()(());
586        HEADER_FN_VAR.get()(HeaderFnArgs {
587            header,
588            background,
589            index,
590            pages_len: pages.len(),
591            titles: pages.iter().map(|p| p.title.0.clone()).collect(),
592            skips: pages.iter().map(|p| p.skip.0.clone()).collect(),
593        })
594    };
595    let side = if side.is_nil() {
596        side
597    } else {
598        let background = SIDE_BACKGROUND_FN_VAR.get()(());
599        let side_extra = SIDE_EXTRA_FN_VAR.get()(args.clone());
600        SIDE_FN_VAR.get()(SideFnArgs {
601            side,
602            background,
603            side_extra,
604            index,
605            pages_len: pages.len(),
606        })
607    };
608    let content = CONTENT_FN_VAR.get()(ContentFnArgs {
609        content,
610        content_fill: page.content_fill,
611        index,
612        pages_len: pages.len(),
613    });
614    let footer_extra = FOOTER_EXTRA_FN_VAR.get()(args);
615    let footer = FOOTER_FN_VAR.get()(FooterFnArgs {
616        footer,
617        footer_extra,
618        index,
619        pages_len: pages.len(),
620    });
621
622    PANEL_FN_VAR.get()(PanelFnArgs {
623        header,
624        side,
625        content,
626        footer,
627    })
628}
629
630/// Controls the parent wizard.
631pub struct WIZARD;
632impl WIZARD {
633    /// Gets the ID of the wizard ancestor represented by the [`WIZARD`].
634    pub fn try_id(&self) -> Option<WidgetId> {
635        if WIZARD_CTX.is_default() { None } else { Some(WIZARD_CTX.get().id) }
636    }
637    /// Gets the ID of the wizard ancestor represented by the [`WIZARD`].
638    ///
639    /// # Panics
640    ///
641    /// Panics if not inside a wizard.
642    pub fn id(&self) -> WidgetId {
643        WIZARD_CTX.get().id
644    }
645
646    /// Request `BACK_CMD`.
647    pub fn back(&self) {
648        BACK_CMD.scoped(self.id()).notify();
649    }
650
651    /// Request `NEXT_CMD`.
652    pub fn next(&self) {
653        NEXT_CMD.scoped(self.id()).notify();
654    }
655
656    /// Request `BEGIN_CMD`.
657    pub fn begin(&self) {
658        BEGIN_CMD.scoped(self.id()).notify();
659    }
660
661    /// Request `BEGIN_CMD` with a custom param.
662    pub fn begin_param(&self, param: impl Any + Send + Sync) {
663        BEGIN_CMD.scoped(self.id()).notify_param(param);
664    }
665
666    /// Request `FINISH_CMD`.
667    pub fn finish(&self) {
668        FINISH_CMD.scoped(self.id()).notify();
669    }
670
671    /// Request `FINISH_CMD` with a custom param.
672    pub fn finish_param(&self, param: impl Any + Send + Sync) {
673        FINISH_CMD.scoped(self.id()).notify_param(param);
674    }
675
676    /// Get or set the selected page.
677    pub fn selected_page(&self) -> Var<Page> {
678        SELECTED_PAGE_VAR.into_var()
679    }
680}