Skip to main content

qframe/widgets/
wizard.rs

1//! Wizards: a multi-step flow with steps on top, one page per step and Back, Next and Finish.
2
3use super::{Button, Steps};
4use crate::event::Event;
5use crate::geometry::{Rect, Size};
6use crate::keymap::Key;
7use crate::widget::{Axis, EventCx, Flex, Length, MeasureCx, Node, NodeMut, PaintCx, View, Widget};
8
9/// Rows between the steps, the page and the buttons.
10const SECTION_GAP: u16 = 1;
11
12/// A flow of pages the user goes through in order: [`Steps`] on top, the current step's page,
13/// and a row of buttons.
14///
15/// The application owns the current step and every value. Next sends the next message; the
16/// application validates the step there and either advances or returns
17/// [`FormErrors::focus_first`](super::FormErrors::focus_first), so a broken step blocks the flow
18/// with focus on the problem. On the last step Next reads Finish and sends the finish message.
19/// Back is shown from the second step on.
20///
21/// Capabilities, each off until asked for: [`Wizard::on_cancel`] adds a Cancel button and makes
22/// Esc inside the wizard cancel; [`Wizard::on_step`] lets people go back by choosing a finished
23/// step; [`Wizard::busy`] shows Next working and ignores it; [`Wizard::page_height`] keeps the
24/// buttons in place across pages of different heights.
25///
26/// The buttons are named `wizard-cancel`, `wizard-back` and `wizard-next`, so
27/// `Command::focus("wizard-next")` reaches them. Their words are `quvyta.wizard.cancel`,
28/// `back`, `next` and `finish`.
29pub struct Wizard<Msg> {
30    labels: Vec<String>,
31    current: usize,
32    on_back: Option<Msg>,
33    on_next: Option<Msg>,
34    on_finish: Option<Msg>,
35    on_cancel: Option<Msg>,
36    on_step: Option<Box<dyn Fn(usize) -> Msg>>,
37    busy: bool,
38    page_height: Option<u16>,
39}
40
41impl<Msg: Clone + 'static> Wizard<Msg> {
42    /// A wizard with steps named `labels`, on the first step.
43    #[must_use]
44    pub fn new(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
45        Self {
46            labels: labels.into_iter().map(Into::into).collect(),
47            current: 0,
48            on_back: None,
49            on_next: None,
50            on_finish: None,
51            on_cancel: None,
52            on_step: None,
53            busy: false,
54            page_height: None,
55        }
56    }
57
58    /// The current step.
59    #[must_use]
60    pub fn current(mut self, index: usize) -> Self {
61        self.current = index;
62        self
63    }
64
65    /// Message for Back.
66    #[must_use]
67    pub fn on_back(mut self, message: Msg) -> Self {
68        self.on_back = Some(message);
69        self
70    }
71
72    /// Message for Next on every step but the last.
73    #[must_use]
74    pub fn on_next(mut self, message: Msg) -> Self {
75        self.on_next = Some(message);
76        self
77    }
78
79    /// Message for Finish on the last step.
80    #[must_use]
81    pub fn on_finish(mut self, message: Msg) -> Self {
82        self.on_finish = Some(message);
83        self
84    }
85
86    /// Adds a Cancel button; Esc inside the wizard sends the same message.
87    #[must_use]
88    pub fn on_cancel(mut self, message: Msg) -> Self {
89        self.on_cancel = Some(message);
90        self
91    }
92
93    /// Lets finished steps be chosen to go back to them; the message carries the step.
94    #[must_use]
95    pub fn on_step(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
96        self.on_step = Some(Box::new(message));
97        self
98    }
99
100    /// Shows Next (or Finish) working and ignores it, e.g. while the last step is applied.
101    #[must_use]
102    pub fn busy(mut self, busy: bool) -> Self {
103        self.busy = busy;
104        self
105    }
106
107    /// Gives every page exactly `rows` rows, so the buttons stay put between steps.
108    #[must_use]
109    pub fn page_height(mut self, rows: u16) -> Self {
110        self.page_height = Some(rows);
111        self
112    }
113
114    /// Adds the wizard to `ui` with the current step's page built by `page`.
115    pub fn show<'v>(self, ui: &'v mut View<'_, Msg>, page: impl FnOnce(&mut View<'_, Msg>)) -> NodeMut<'v, Msg> {
116        let mut children = Vec::new();
117        {
118            let ui = &mut ui.nested(&mut children);
119            let mut steps = Steps::new(self.labels.clone()).current(self.current).running(self.busy);
120            if let Some(message) = self.on_step {
121                steps = steps.on_select(message);
122            }
123            ui.add(steps).id("wizard-steps");
124            let body = ui.column(page).fill_width().id("wizard-page");
125            if let Some(rows) = self.page_height {
126                body.height(Length::Cells(rows));
127            }
128            let last = self.current + 1 >= self.labels.len();
129            let (next_label, next) = if last {
130                (crate::t!("quvyta.wizard.finish"), self.on_finish.clone())
131            } else {
132                (crate::t!("quvyta.wizard.next"), self.on_next.clone())
133            };
134            let busy = self.busy;
135            let (on_back, on_cancel, current) = (self.on_back.clone(), self.on_cancel.clone(), self.current);
136            ui.row(|ui| {
137                if let Some(cancel) = on_cancel {
138                    ui.add(Button::new(crate::t!("quvyta.wizard.cancel")).disabled(busy).on_press(cancel))
139                        .id("wizard-cancel");
140                }
141                ui.spacer();
142                if current > 0
143                    && let Some(back) = on_back
144                {
145                    ui.add(Button::new(crate::t!("quvyta.wizard.back")).disabled(busy).on_press(back))
146                        .id("wizard-back");
147                }
148                let mut button = Button::new(next_label).variant("primary").loading(busy);
149                if let Some(message) = next {
150                    button = button.on_press(message);
151                }
152                ui.add(button).id("wizard-next");
153            })
154            .gap(2)
155            .fill_width();
156        }
157        let mut column = Node::new(Flex::new(Axis::Column, children), 0);
158        column.layout.gap = SECTION_GAP;
159        column.layout.width = Length::Fill(1);
160        ui.add(WizardFrame { body: vec![column], on_cancel: self.on_cancel }).fill_width()
161    }
162}
163
164/// The wizard's outer node: lays out its column and turns Esc into the cancel message.
165struct WizardFrame<Msg> {
166    body: Vec<Node<Msg>>,
167    on_cancel: Option<Msg>,
168}
169
170impl<Msg: Clone + 'static> Widget<Msg> for WizardFrame<Msg> {
171    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
172        self.body.first().map_or(Size::default(), |body| cx.measure_child(body, available))
173    }
174
175    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
176        if let Some(body) = self.body.first() {
177            cx.paint_child(body, area);
178        }
179    }
180
181    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
182        match (event, &self.on_cancel) {
183            (Event::Key(key), Some(message)) if key.is_plain(Key::Esc) => {
184                cx.emit(message.clone());
185                true
186            }
187            _ => false,
188        }
189    }
190
191    fn children(&self) -> &[Node<Msg>] {
192        &self.body
193    }
194
195    fn children_mut(&mut self) -> &mut [Node<Msg>] {
196        &mut self.body
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::runtime::{App, Command, Harness};
204    use crate::widgets::{FormErrors, TextInput};
205
206    #[derive(Default)]
207    struct Setup {
208        step: usize,
209        name: String,
210        errors: FormErrors,
211        finished: bool,
212        cancelled: bool,
213        cancellable: bool,
214    }
215
216    #[derive(Clone)]
217    enum Msg {
218        Name(String),
219        Back,
220        Next,
221        Finish,
222        Cancel,
223    }
224
225    impl App for Setup {
226        type Msg = Msg;
227        fn update(&mut self, msg: Msg) -> Command<Msg> {
228            match msg {
229                Msg::Name(name) => self.name = name,
230                Msg::Back => self.step -= 1,
231                Msg::Next => {
232                    self.errors.check("name", !self.name.is_empty(), "Name the project");
233                    if !self.errors.is_empty() {
234                        return self.errors.focus_first();
235                    }
236                    self.step += 1;
237                }
238                Msg::Finish => self.finished = true,
239                Msg::Cancel => self.cancelled = true,
240            }
241            Command::none()
242        }
243        fn view(&self, ui: &mut View<'_, Msg>) {
244            let mut wizard = Wizard::new(["Project", "Summary"])
245                .current(self.step)
246                .on_back(Msg::Back)
247                .on_next(Msg::Next)
248                .on_finish(Msg::Finish);
249            if self.cancellable {
250                wizard = wizard.on_cancel(Msg::Cancel);
251            }
252            wizard.page_height(2).show(ui, |ui| {
253                if self.step == 0 {
254                    ui.add(TextInput::new(&self.name).on_change(Msg::Name)).width(Length::Cells(20)).id("name");
255                } else {
256                    ui.add(crate::widgets::Text::new(format!("Create {}", self.name)));
257                }
258            });
259        }
260    }
261
262    #[test]
263    fn next_is_blocked_by_validation_then_advances_and_finishes() {
264        let mut h = Harness::new(Setup::default(), 40, 8);
265        assert_eq!(h.screen(), "●  Project   ○  Summary\n\n  ❯\n\n\n                                  Next\n\n\n");
266        h.click_text("Next");
267        assert_eq!(h.app().step, 0);
268        assert!(h.is_focused("name"), "focus goes to the problem");
269        h.type_text("web");
270        h.click_text("Next");
271        assert_eq!(h.app().step, 1);
272        let screen = h.screen();
273        assert!(screen.contains("✓  Project   ●  Summary"), "{screen}");
274        // The pointer rests where Next was, so Finish shows the hover pillar.
275        assert!(screen.contains("Back    ▌ Finish"), "{screen}");
276        h.click_text("Finish");
277        assert!(h.app().finished);
278        h.click_text("Back");
279        assert_eq!(h.app().step, 0);
280    }
281
282    #[test]
283    fn cancel_is_a_button_and_esc_only_when_asked_for() {
284        let mut h = Harness::new(Setup::default(), 40, 8);
285        h.press("tab").press("esc");
286        assert!(!h.app().cancelled);
287        assert!(!h.screen().contains("Cancel"));
288        let mut h = Harness::new(Setup { cancellable: true, ..Setup::default() }, 40, 8);
289        h.press("tab").press("esc");
290        assert!(h.app().cancelled);
291        assert!(h.screen().contains("Cancel"));
292    }
293}