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