Skip to main content

ravenrs_widgets/
wizard.rs

1//! Going through a task one step at a time.
2
3use std::fmt;
4use std::rc::Rc;
5
6use ravenrs_core::{Border, Color, CursorIcon, Insets, Role};
7use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
8use ravenrs_reactive::{Binding, State};
9use ravenrs_style::{Semantic, TextStyle, Weight};
10
11use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
12use crate::{Alert, AnyView, Button, ButtonStyle, IconName, Tone, View, util};
13
14type StepCheck = Rc<dyn Fn() -> Result<(), String>>;
15
16/// One step of a [`Wizard`].
17#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
18pub struct WizardStep {
19    title: String,
20    content: AnyView,
21    check: Option<StepCheck>,
22    failed: bool,
23}
24
25impl fmt::Debug for WizardStep {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        f.debug_struct("WizardStep").field("title", &self.title).finish_non_exhaustive()
28    }
29}
30
31#[cfg_attr(feature = "live", ravenrs_live::expose)]
32impl WizardStep {
33    /// A step with this title and content.
34    pub fn new(title: impl Into<String>, content: impl View + 'static) -> Self {
35        Self { title: title.into(), content: Box::new(content), check: None, failed: false }
36    }
37
38    /// What has to hold to leave this step forward. The error is shown on the step,
39    /// and going back never asks.
40    pub fn check(mut self, check: impl Fn() -> Result<(), String> + 'static) -> Self {
41        self.check = Some(Rc::new(check));
42        self
43    }
44
45    /// Marks the step as having a problem, in the row of steps at the top, whichever
46    /// step is showing. It does not stop anyone from leaving it: [`Self::check`] does.
47    pub fn failed(mut self, failed: bool) -> Self {
48        self.failed = failed;
49        self
50    }
51}
52
53/// A task in steps: where it stands at the top, one step on the screen, and buttons
54/// to go on. A step can refuse to be left, and steps already done can be reopened.
55///
56/// ```
57/// use ravenrs_reactive::State;
58/// use ravenrs_widgets::{Text, Ui, Wizard, WizardStep};
59///
60/// let step = State::new(0);
61/// let wizard = Wizard::new(
62///     step.binding(),
63///     vec![
64///         WizardStep::new("Data", Text::new("Where it comes from")),
65///         WizardStep::new("Layout", Text::new("How it looks")).check(|| Err("Pick one".into())),
66///     ],
67/// )
68/// .on_finish(|| println!("done"));
69/// let mut ui = Ui::new(wizard, ravenrs_style::Theme::raven_dark());
70/// ui.update();
71/// ```
72#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
73pub struct Wizard {
74    step: Binding<usize>,
75    steps: Vec<WizardStep>,
76    on_finish: Option<Rc<dyn Fn()>>,
77    on_cancel: Option<Rc<dyn Fn()>>,
78    finish_label: Option<String>,
79}
80
81impl fmt::Debug for Wizard {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        f.debug_struct("Wizard").field("steps", &self.steps).finish_non_exhaustive()
84    }
85}
86
87#[cfg_attr(feature = "live", ravenrs_live::expose)]
88impl Wizard {
89    /// A wizard over these steps, bound to the index of the one showing.
90    pub fn new(step: Binding<usize>, steps: Vec<WizardStep>) -> Self {
91        Self { step, steps, on_finish: None, on_cancel: None, finish_label: None }
92    }
93
94    /// Runs after the last step's check passes.
95    pub fn on_finish(mut self, handler: impl Fn() + 'static) -> Self {
96        self.on_finish = Some(Rc::new(handler));
97        self
98    }
99
100    /// Shows a Cancel button that runs this.
101    pub fn on_cancel(mut self, handler: impl Fn() + 'static) -> Self {
102        self.on_cancel = Some(Rc::new(handler));
103        self
104    }
105
106    /// The text of the last button.
107    pub fn finish_label(mut self, label: impl Into<String>) -> Self {
108        self.finish_label = Some(label.into());
109        self
110    }
111}
112
113/// Where a step of a task stands.
114#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
115#[non_exhaustive]
116#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
117pub enum StepState {
118    /// Not reached yet.
119    #[default]
120    Upcoming,
121    /// The one being done now.
122    Current,
123    /// Finished.
124    Done,
125    /// Has a problem to look at.
126    Error,
127}
128
129/// The round mark of a step. Besides its color it has a shape of its own in each state:
130/// a number, a check mark, or an alert over a ring.
131fn step_dot(cx: &mut BuildCx<'_>, number: usize, state: StepState, size: f32) -> NodeId {
132    let theme = *cx.theme();
133    let (muted, on_accent, danger) =
134        (theme.color(Semantic::InkMuted), theme.color(Semantic::OnAccent), theme.color(Semantic::Danger));
135    let icon_size = size * 7.0 / 12.0;
136    let mark = match state {
137        StepState::Done => util::icon(cx, IconName::Check, icon_size, on_accent),
138        StepState::Error => util::icon(cx, IconName::CircleAlert, icon_size, danger),
139        StepState::Current | StepState::Upcoming => {
140            let color = if state == StepState::Current { on_accent } else { muted };
141            util::label(cx, &number.to_string(), TextStyle::Callout, Some(Weight::SEMIBOLD), color)
142        }
143    };
144    let paint = match state {
145        StepState::Done | StepState::Current => QuadPaint::new(theme.color(Semantic::Accent)),
146        StepState::Upcoming => QuadPaint::new(theme.color(Semantic::Ink).with_alpha(0.08)),
147        StepState::Error => QuadPaint {
148            border: Some(Border { width: 1.0, color: danger }),
149            ..QuadPaint::new(Color::TRANSPARENT)
150        },
151    };
152    cx.container(
153        LayoutStyle {
154            width: Some(size),
155            height: Some(size),
156            align: Align::Center,
157            justify: Justify::Center,
158            ..LayoutStyle::stack(Axis::Horizontal)
159        },
160        &[mark],
161        Paint::Quad(QuadPaint { corner_radius: theme.radii.full, ..paint }),
162    )
163}
164
165/// The word for a state, for screen readers; the current step says so by being selected.
166fn step_state_text(cx: &BuildCx<'_>, state: StepState) -> Option<String> {
167    let labels = cx.labels();
168    match state {
169        StepState::Done => Some(labels.wizard_done.clone()),
170        StepState::Error => Some(labels.wizard_error.clone()),
171        StepState::Current | StepState::Upcoming => None,
172    }
173}
174
175/// The round mark of a step by itself: its number, a check mark when done, or an alert
176/// when it has a problem. For a list of steps, see [`StepIndicator`].
177///
178/// ```
179/// use ravenrs_widgets::{StepBadge, StepState, Ui};
180///
181/// let mut ui = Ui::new(StepBadge::new(2).state(StepState::Done), ravenrs_style::Theme::raven_dark());
182/// ui.update();
183/// ```
184#[derive(Clone, Copy, Debug)]
185#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
186pub struct StepBadge {
187    number: usize,
188    state: StepState,
189    size: f32,
190}
191
192#[cfg_attr(feature = "live", ravenrs_live::expose)]
193impl StepBadge {
194    /// The mark of the step with this number, not reached yet.
195    pub fn new(number: usize) -> Self {
196        Self { number, state: StepState::Upcoming, size: 24.0 }
197    }
198
199    /// Where the step stands.
200    pub fn state(mut self, state: StepState) -> Self {
201        self.state = state;
202        self
203    }
204
205    /// Its diameter in logical pixels (24 by default).
206    pub fn size(mut self, size: f32) -> Self {
207        self.size = size;
208        self
209    }
210}
211
212impl View for StepBadge {
213    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
214        let node = step_dot(cx, self.number, self.state, self.size);
215        let mut semantics = Semantics::new(Role::Image)
216            .name(self.number.to_string())
217            .selected(self.state == StepState::Current);
218        if let Some(text) = step_state_text(cx, self.state) {
219            semantics = semantics.description(text);
220        }
221        cx.semantics(node, semantics);
222        node
223    }
224}
225
226/// One line of the row of steps: its mark, its title, and what it says under the title.
227struct StepRow<'a> {
228    number: usize,
229    title: &'a str,
230    note: Option<&'a str>,
231    state: StepState,
232    on_click: Option<Rc<dyn Fn()>>,
233    stretch: bool,
234}
235
236impl View for StepRow<'_> {
237    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
238        let theme = *cx.theme();
239        let (ink, muted) = (theme.color(Semantic::Ink), theme.color(Semantic::InkMuted));
240        let here = self.state == StepState::Current;
241        let dot = step_dot(cx, self.number, self.state, 24.0);
242        cx.semantics(dot, Semantics::hidden());
243        let weight = here.then_some(Weight::SEMIBOLD);
244        let color = if self.state == StepState::Upcoming { muted } else { ink };
245        let title = util::label(cx, self.title, TextStyle::Callout, weight, color);
246        let text = match self.note {
247            Some(note) => {
248                let note = util::label(cx, note, TextStyle::Caption, None, muted);
249                cx.container(
250                    LayoutStyle { grow: 1.0, spacing: 2.0, ..LayoutStyle::stack(Axis::Vertical) },
251                    &[title, note],
252                    Paint::None,
253                )
254            }
255            None => title,
256        };
257        let clickable = self.on_click.is_some();
258        let node = cx.container(
259            LayoutStyle {
260                padding: Insets::symmetric(4.0, 6.0),
261                spacing: 8.0,
262                align: Align::Center,
263                align_self: self.stretch.then_some(Align::Stretch),
264                ..LayoutStyle::stack(Axis::Horizontal)
265            },
266            &[dot, text],
267            Paint::Quad(QuadPaint {
268                corner_radius: theme.radii.sm,
269                ..QuadPaint::new(if clickable && cx.is_hovered() {
270                    ink.with_alpha(0.05)
271                } else {
272                    Color::TRANSPARENT
273                })
274            }),
275        );
276        let role = if clickable { Role::Button } else { Role::ListItem };
277        let mut semantics = Semantics::new(role).name(self.title.to_owned());
278        // The note goes before the state, the way it reads on the screen.
279        let description: Vec<String> =
280            self.note.map(str::to_owned).into_iter().chain(step_state_text(cx, self.state)).collect();
281        if !description.is_empty() {
282            semantics = semantics.description(description.join(", "));
283        }
284        if here || !clickable {
285            semantics = semantics.selected(here);
286        }
287        cx.semantics(node, semantics);
288        if let Some(on_click) = &self.on_click {
289            cx.on_click(node, on_click.clone());
290            cx.focusable(node, theme.radii.sm);
291            cx.cursor(node, CursorIcon::Pointer);
292        }
293        node
294    }
295}
296
297/// One step shown by a [`StepIndicator`].
298#[derive(Clone, Debug)]
299#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
300pub struct IndicatorStep {
301    title: String,
302    state: StepState,
303    note: Option<String>,
304}
305
306#[cfg_attr(feature = "live", ravenrs_live::expose)]
307impl IndicatorStep {
308    /// A step with this title, in this state.
309    pub fn new(title: impl Into<String>, state: StepState) -> Self {
310        Self { title: title.into(), state, note: None }
311    }
312
313    /// A short line under the title, such as what is missing.
314    pub fn note(mut self, note: impl Into<String>) -> Self {
315        self.note = Some(note.into());
316        self
317    }
318}
319
320/// Where a task in steps stands, for an app that lays out the steps itself. A
321/// [`Wizard`] has its own row of these.
322///
323/// ```
324/// use ravenrs_widgets::{IndicatorStep, StepIndicator, StepState, Ui};
325///
326/// let steps = StepIndicator::new(vec![
327///     IndicatorStep::new("Client", StepState::Done),
328///     IndicatorStep::new("Photos", StepState::Error).note("Two are missing"),
329///     IndicatorStep::new("Review", StepState::Current),
330/// ])
331/// .vertical()
332/// .on_select(|step| println!("go to {step}"));
333/// let mut ui = Ui::new(steps, ravenrs_style::Theme::raven_dark());
334/// ui.update();
335/// ```
336#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
337pub struct StepIndicator {
338    steps: Vec<IndicatorStep>,
339    vertical: bool,
340    on_select: Option<Rc<dyn Fn(usize)>>,
341}
342
343impl fmt::Debug for StepIndicator {
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        f.debug_struct("StepIndicator").field("steps", &self.steps).finish_non_exhaustive()
346    }
347}
348
349#[cfg_attr(feature = "live", ravenrs_live::expose)]
350impl StepIndicator {
351    /// A row of these steps.
352    pub fn new(steps: Vec<IndicatorStep>) -> Self {
353        Self { steps, vertical: false, on_select: None }
354    }
355
356    /// Lists the steps one under the other.
357    pub fn vertical(mut self) -> Self {
358        self.vertical = true;
359        self
360    }
361
362    /// Makes the steps buttons: a click, Enter or Space runs `handler` with the step's
363    /// position, 0 being the first.
364    pub fn on_select(mut self, handler: impl Fn(usize) + 'static) -> Self {
365        self.on_select = Some(Rc::new(handler));
366        self
367    }
368}
369
370impl View for StepIndicator {
371    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
372        let labels = cx.labels();
373        let mut rows = Vec::with_capacity(self.steps.len());
374        for (i, step) in self.steps.iter().enumerate() {
375            let on_click = self.on_select.clone().map(|select| Rc::new(move || select(i)) as Rc<dyn Fn()>);
376            rows.push(cx.keyed_child(
377                i as u64,
378                &StepRow {
379                    number: i + 1,
380                    title: &step.title,
381                    note: step.note.as_deref(),
382                    state: step.state,
383                    on_click,
384                    stretch: self.vertical,
385                },
386            ));
387        }
388        let style = if self.vertical {
389            LayoutStyle { spacing: 4.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) }
390        } else {
391            LayoutStyle {
392                spacing: 4.0,
393                wrap: true,
394                align: Align::Center,
395                ..LayoutStyle::stack(Axis::Horizontal)
396            }
397        };
398        let node = cx.container(style, &rows, Paint::None);
399        let current = self.steps.iter().position(|s| s.state == StepState::Current).map_or(0, |i| i + 1);
400        cx.semantics(
401            node,
402            Semantics::new(Role::List).name(labels.wizard_progress(current, self.steps.len())),
403        );
404        node
405    }
406}
407
408/// What the forward button does: checks the step, then moves on or finishes.
409fn advance(
410    check: Option<StepCheck>,
411    error: &State<Option<String>>,
412    step: &Binding<usize>,
413    next: Option<usize>,
414    finish: Option<&Rc<dyn Fn()>>,
415) {
416    if let Some(check) = check
417        && let Err(message) = check()
418    {
419        error.set(Some(message));
420        return;
421    }
422    error.set(None);
423    match next {
424        Some(next) => step.set(next),
425        None => {
426            if let Some(finish) = finish {
427                finish();
428            }
429        }
430    }
431}
432
433impl View for Wizard {
434    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
435        let labels = cx.labels();
436        let error: State<Option<String>> = cx.use_state(|| None);
437        let count = self.steps.len();
438        if count == 0 {
439            return cx.empty();
440        }
441        let current = self.step.get().min(count - 1);
442        let mut marks = Vec::with_capacity(count);
443        for (index, step) in self.steps.iter().enumerate() {
444            let state = match index.cmp(&current) {
445                _ if step.failed => StepState::Error,
446                std::cmp::Ordering::Less => StepState::Done,
447                std::cmp::Ordering::Equal => StepState::Current,
448                std::cmp::Ordering::Greater => StepState::Upcoming,
449            };
450            // Steps already gone through can be reopened.
451            let on_click = (index < current).then(|| {
452                let (moved, error) = (self.step.clone(), error.clone());
453                Rc::new(move || {
454                    error.set(None);
455                    moved.set(index);
456                }) as Rc<dyn Fn()>
457            });
458            let mark = StepRow {
459                number: index + 1,
460                title: &step.title,
461                note: None,
462                state,
463                on_click,
464                stretch: false,
465            };
466            marks.push(cx.keyed_child(index as u64, &mark));
467        }
468        let header = cx.container(
469            LayoutStyle {
470                spacing: 4.0,
471                wrap: true,
472                align: Align::Center,
473                ..LayoutStyle::stack(Axis::Horizontal)
474            },
475            &marks,
476            Paint::None,
477        );
478        cx.semantics(header, Semantics::new(Role::List).name(labels.wizard_progress(current + 1, count)));
479        let step = &self.steps[current];
480        let mut body = Vec::new();
481        if let Some(message) = error.get() {
482            body.push(cx.child(&Alert::new(step.title.clone(), message).tone(Tone::Danger)));
483        }
484        body.push(cx.keyed_child(current as u64 + 1, step.content.as_ref()));
485        let content = cx.container(
486            LayoutStyle {
487                grow: 1.0,
488                spacing: 12.0,
489                align: Align::Stretch,
490                align_self: Some(Align::Stretch),
491                ..LayoutStyle::stack(Axis::Vertical)
492            },
493            &body,
494            Paint::None,
495        );
496        let mut buttons = Vec::new();
497        if let Some(cancel) = &self.on_cancel {
498            let cancel = cancel.clone();
499            buttons.push(
500                cx.child(
501                    &Button::new(labels.wizard_cancel.clone())
502                        .style(ButtonStyle::Ghost)
503                        .on_click(move || cancel()),
504                ),
505            );
506        }
507        buttons.push(cx.container(LayoutStyle { grow: 1.0, ..LayoutStyle::leaf() }, &[], Paint::None));
508        if current > 0 {
509            let (back, failed) = (self.step.clone(), error.clone());
510            buttons.push(cx.child(
511                &Button::new(labels.wizard_back.clone()).style(ButtonStyle::Secondary).on_click(move || {
512                    failed.set(None);
513                    back.set(current - 1);
514                }),
515            ));
516        }
517        let last = current + 1 == count;
518        let text = if last {
519            self.finish_label.clone().unwrap_or_else(|| labels.wizard_finish.clone())
520        } else {
521            labels.wizard_next.clone()
522        };
523        let (check, failed, moved, finish) =
524            (step.check.clone(), error.clone(), self.step.clone(), self.on_finish.clone());
525        buttons.push(cx.child(&Button::new(text).on_click(move || {
526            advance(check.clone(), &failed, &moved, (!last).then_some(current + 1), finish.as_ref());
527        })));
528        let footer = cx.container(
529            LayoutStyle {
530                spacing: 8.0,
531                align: Align::Center,
532                align_self: Some(Align::Stretch),
533                ..LayoutStyle::stack(Axis::Horizontal)
534            },
535            &buttons,
536            Paint::None,
537        );
538        cx.container(
539            LayoutStyle { spacing: 16.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
540            &[header, content, footer],
541            Paint::None,
542        )
543    }
544}