Skip to main content

teksilo_widgets/stepper/
wizard.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`Wizard`] — a thin modal launcher around [`Stepper`].
5//!
6//! Renders as a button (or a custom `.trigger(...)` widget) that opens a modal
7//! containing a `Stepper` built from the same [`Step`]s. The modal's Cancel and
8//! a wrapped Finish both dismiss it.
9
10use std::rc::Rc;
11
12use teksilo_canvas::{Rect, SizeProposal};
13use teksilo_core::accessibility::AccessNodeBuilder;
14use teksilo_core::build_context::BuildContext;
15use teksilo_core::modal::{ModalCloseBehavior, ModalPresentation, ModalRequest};
16use teksilo_core::signal::Prop;
17use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
18use teksilo_core::widget_id::WidgetId;
19use teksilo_i18n::{LocalizedString, lit};
20
21use super::Stepper;
22use super::controller::StepperController;
23use super::nav::{FinishAction, FinishOutcome, IntoFinishOutcome};
24use super::step::Step;
25use crate::button::{Button, ButtonVariant};
26use crate::dialog::ModalContainer;
27use crate::overlay_trigger::OverlayTrigger;
28
29const DEFAULT_WIZARD_WIDTH: u32 = 640;
30const DEFAULT_WIZARD_HEIGHT: u32 = 460;
31
32/// The shared, cloneable modal recipe captured by the trigger handlers.
33struct WizardSpec {
34    title: LocalizedString,
35    steps: Vec<Step>,
36    presentation: ModalPresentation,
37    close_behavior: ModalCloseBehavior,
38    size: (u32, u32),
39    non_linear: bool,
40    back_label: LocalizedString,
41    next_label: LocalizedString,
42    finish_label: LocalizedString,
43    skip_label: LocalizedString,
44    cancel_label: LocalizedString,
45    finish_action: Option<FinishAction>,
46}
47
48fn present_wizard(spec: &Rc<WizardSpec>, ctx: &mut EventContext) {
49    if spec.steps.is_empty() {
50        return;
51    }
52    let spec = spec.clone();
53    let presentation = spec.presentation;
54    let close_behavior = spec.close_behavior;
55    let (w, h) = spec.size;
56    let title = spec.title.resolve_now();
57    ctx.present_modal(
58        ModalRequest::deferred(move |tree| {
59            let finish = spec.finish_action.clone();
60            let stepper = Stepper::new()
61                .steps(spec.steps.clone())
62                .non_linear(spec.non_linear)
63                .back_label(spec.back_label.clone())
64                .next_label(spec.next_label.clone())
65                .finish_label(spec.finish_label.clone())
66                .skip_label(spec.skip_label.clone())
67                .cancel(spec.cancel_label.clone(), |ctx, _ctrl| ctx.dismiss_modal())
68                // Dismiss only on a successful finish: a callback that
69                // returns `Rejected` (a commit that failed) keeps the modal
70                // open on the last step so the user can correct and retry.
71                .on_finish(move |ctx, ctrl| {
72                    let outcome = match &finish {
73                        Some(action) => action(ctx, ctrl),
74                        None => FinishOutcome::Finished,
75                    };
76                    if outcome == FinishOutcome::Finished {
77                        ctx.dismiss_modal();
78                    }
79                    outcome
80                });
81            tree.add(ModalContainer::boxed(Box::new(stepper)))
82        })
83        .presentation(presentation)
84        .close_behavior(close_behavior)
85        .title(title)
86        .size(w, h),
87    );
88}
89
90/// A button (or custom trigger) that opens a modal [`Stepper`].
91///
92/// `Wizard::new(label)` renders as a `Filled` [`Button`] whose tap opens a
93/// full-screen modal containing a [`Stepper`] built from the same [`Step`]s.
94/// The modal's auto-injected Cancel button and the wrapped Finish both dismiss
95/// it. Override the trigger with [`trigger`](Self::trigger) to use any widget
96/// instead of the default button.
97pub struct Wizard {
98    label: LocalizedString,
99    variant: ButtonVariant,
100    /// Enabled state, static or reactive; forwarded to the arena at build
101    /// time.
102    enabled: Prop<bool>,
103    presentation: ModalPresentation,
104    close_behavior: ModalCloseBehavior,
105    size: (u32, u32),
106    non_linear: bool,
107    steps: Vec<Step>,
108    back_label: LocalizedString,
109    next_label: LocalizedString,
110    finish_label: LocalizedString,
111    skip_label: LocalizedString,
112    cancel_label: LocalizedString,
113    finish_action: Option<FinishAction>,
114    pending_trigger: Option<Box<dyn Widget>>,
115    root_child_id: Option<WidgetId>,
116}
117
118impl Wizard {
119    /// Create a wizard trigger button with the given label. The label is also
120    /// used as the modal title.
121    pub fn new(label: impl Into<LocalizedString>) -> Self {
122        Self {
123            label: label.into(),
124            variant: ButtonVariant::Filled,
125            enabled: Prop::Static(true),
126            presentation: ModalPresentation::Auto,
127            close_behavior: ModalCloseBehavior::Manual,
128            size: (DEFAULT_WIZARD_WIDTH, DEFAULT_WIZARD_HEIGHT),
129            non_linear: false,
130            steps: Vec::new(),
131            back_label: lit!("Back"),
132            next_label: lit!("Next"),
133            finish_label: lit!("Finish"),
134            skip_label: lit!("Skip"),
135            cancel_label: lit!("Cancel"),
136            finish_action: None,
137            pending_trigger: None,
138            root_child_id: None,
139        }
140    }
141
142    /// Append a single [`Step`] to the wizard.
143    pub fn step(mut self, step: Step) -> Self {
144        self.steps.push(step);
145        self
146    }
147    /// Append multiple [`Step`]s from an iterator.
148    pub fn steps(mut self, steps: impl IntoIterator<Item = Step>) -> Self {
149        self.steps.extend(steps);
150        self
151    }
152    /// Set the visual variant of the trigger button (default `Filled`).
153    pub fn variant(mut self, variant: ButtonVariant) -> Self {
154        self.variant = variant;
155        self
156    }
157    /// Enable or disable the trigger button, statically or reactively.
158    /// When disabled, tapping or pressing the trigger is a no-op.
159    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
160        self.enabled = enabled.into();
161        self
162    }
163    /// Allow jumping between steps by clicking their indicators (the
164    /// markers become `Role::Tab`). Default: linear.
165    pub fn non_linear(mut self, non_linear: bool) -> Self {
166        self.non_linear = non_linear;
167        self
168    }
169    /// Control how the modal is presented (auto, sheet, full-screen, …).
170    pub fn presentation(mut self, presentation: ModalPresentation) -> Self {
171        self.presentation = presentation;
172        self
173    }
174    /// Control how the modal is dismissed (manual, click-outside, …).
175    pub fn close_behavior(mut self, close_behavior: ModalCloseBehavior) -> Self {
176        self.close_behavior = close_behavior;
177        self
178    }
179    /// Set the preferred modal size in logical pixels. Default 640 × 460.
180    pub fn size(mut self, width: u32, height: u32) -> Self {
181        self.size = (width, height);
182        self
183    }
184    /// Override the "Back" button label inside the modal. Default: "Back".
185    pub fn back_label(mut self, label: impl Into<LocalizedString>) -> Self {
186        self.back_label = label.into();
187        self
188    }
189    /// Override the "Next" button label inside the modal. Default: "Next".
190    pub fn next_label(mut self, label: impl Into<LocalizedString>) -> Self {
191        self.next_label = label.into();
192        self
193    }
194    /// Override the "Finish" button label inside the modal. Default: "Finish".
195    pub fn finish_label(mut self, label: impl Into<LocalizedString>) -> Self {
196        self.finish_label = label.into();
197        self
198    }
199    /// Override the "Skip" button label inside the modal. Default: "Skip".
200    pub fn skip_label(mut self, label: impl Into<LocalizedString>) -> Self {
201        self.skip_label = label.into();
202        self
203    }
204    /// Override the "Cancel" button label inside the modal. Default: "Cancel".
205    pub fn cancel_label(mut self, label: impl Into<LocalizedString>) -> Self {
206        self.cancel_label = label.into();
207        self
208    }
209    /// Called when Finish is activated on the last step.
210    ///
211    /// The callback may refuse: its return value goes through
212    /// [`IntoFinishOutcome`] (`()` always succeeds; `false` / `Err(_)` /
213    /// [`FinishOutcome::Rejected`] do not). A rejected finish leaves the modal
214    /// **open** on the last step and marks it
215    /// [`StepStatus::Error`](super::StepStatus::Error) — the right response to
216    /// a commit that failed.
217    pub fn on_finish<R: IntoFinishOutcome>(
218        mut self,
219        action: impl Fn(&mut EventContext, &StepperController) -> R + 'static,
220    ) -> Self {
221        self.finish_action = Some(Rc::new(move |ctx, ctrl| {
222            action(ctx, ctrl).into_finish_outcome()
223        }));
224        self
225    }
226    pub fn trigger(mut self, trigger: impl Widget + 'static) -> Self {
227        self.pending_trigger = Some(Box::new(trigger));
228        self
229    }
230
231    fn spec(&self) -> Rc<WizardSpec> {
232        Rc::new(WizardSpec {
233            title: self.label.clone(),
234            steps: self.steps.clone(),
235            presentation: self.presentation,
236            close_behavior: self.close_behavior,
237            size: self.size,
238            non_linear: self.non_linear,
239            back_label: self.back_label.clone(),
240            next_label: self.next_label.clone(),
241            finish_label: self.finish_label.clone(),
242            skip_label: self.skip_label.clone(),
243            cancel_label: self.cancel_label.clone(),
244            finish_action: self.finish_action.clone(),
245        })
246    }
247}
248
249impl std::fmt::Debug for Wizard {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        f.debug_struct("Wizard")
252            .field("label", &self.label)
253            .field("steps", &self.steps.len())
254            .finish()
255    }
256}
257
258impl Widget for Wizard {
259    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
260        let enabled = self.enabled.clone();
261        let spec = self.spec();
262
263        let root_id = if let Some(trigger) = self.pending_trigger.take() {
264            // `OverlayTrigger` routes this one opener to the pointer, to
265            // Enter/Space and to the AT `Click`, on the nodes each belongs on.
266            let open = {
267                let spec = spec.clone();
268                let enabled = enabled.clone();
269                move |ctx: &mut EventContext| {
270                    if enabled.get() {
271                        present_wizard(&spec, ctx);
272                    }
273                }
274            };
275            ctx.add(
276                OverlayTrigger::new(trigger)
277                    .on_activate(open)
278                    .activate_on_key_up()
279                    .enabled(self.enabled.clone())
280                    .name(self.label.clone()),
281            )
282        } else {
283            ctx.add(
284                Button::new(self.label.clone())
285                    .variant(self.variant)
286                    .enabled(enabled.clone())
287                    .on_activate_fn(move |ctx| {
288                        if enabled.get() {
289                            present_wizard(&spec, ctx);
290                        }
291                    }),
292            )
293        };
294
295        self.root_child_id = Some(root_id);
296        vec![root_id]
297    }
298
299    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
300        self.root_child_id
301            .and_then(|id| ctx.child_size(id, proposal))
302            .unwrap_or_else(|| proposal.resolve(140.0, 40.0))
303            .into()
304    }
305
306    fn place_children(
307        &self,
308        bounds: Rect,
309        _proposal: SizeProposal,
310        children: &mut [WidgetPlacement],
311        _ctx: &LayoutContext,
312    ) {
313        for child in children.iter_mut() {
314            child.origin = bounds.origin();
315            child.size = bounds.size();
316        }
317    }
318
319    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
320        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
321    }
322
323    fn children(&self) -> Vec<WidgetId> {
324        self.root_child_id.into_iter().collect()
325    }
326}