Skip to main content

teksilo_widgets/
stepper.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`Stepper`] — a modern, embeddable step-flow widget (Material/Ant/Flutter
5//! "stepper"), and [`Wizard`], a thin modal launcher built on it.
6//!
7//! A stepper shows a **visible step-indicator strip** above (or beside) a
8//! content area driven by a [`Switcher`], with a
9//! footer of Back / Skip / Help / Next / Finish controls. It supports linear
10//! and **non-linear** (clickable) navigation, optional + skippable steps, per
11//! step validation gating, a generic chrome slot, and a
12//! [`StepperController`] handle for programmatic reset / jump / introspection.
13//!
14//! # Data flow
15//!
16//! The application owns its form state as `Signal`s. A step's content factory
17//! captures clones of those signals (write side); [`Step::complete_when`]
18//! derives the Next gate from the same signals; and
19//! [`Stepper::on_finish`] reads them back — plus the [`StepperController`] for
20//! per-step introspection (`visited` / `skipped`) — to branch on the choices
21//! made. There is no `QVariant` field registry: plain shared signals are the
22//! cross-step channel.
23//!
24//! ```ignore
25//! #[derive(Clone)]
26//! struct Form { name: Signal<String>, plan: Signal<Plan> }
27//! let form = Form { name: Signal::new(String::new()), plan: Signal::new(Plan::Free) };
28//!
29//! Stepper::new()
30//!     .step(Step::new(lit!("Account"))
31//!         .content({ let f = form.clone(); move || TextInput::new().text(f.name.clone()) })
32//!         .complete_when(form.name.map(|n| !n.is_empty())))
33//!     .step(Step::new(lit!("Plan"))
34//!         .content({ let f = form.clone(); move || plan_picker(f.plan.clone()) }))
35//!     .on_finish({ let f = form.clone(); move |_ctx, ctrl| {
36//!         match f.plan.get() { Plan::Free => {/* … */} Plan::Pro => {/* … */} }
37//!         let _ = ctrl.skipped(1);
38//!     }});
39//! ```
40
41mod content_pane;
42mod controller;
43mod footer;
44mod indicator;
45mod indicator_strip;
46mod nav;
47mod step;
48mod wizard;
49
50#[cfg(test)]
51mod tests;
52
53use std::cell::RefCell;
54use std::rc::Rc;
55
56use teksilo_canvas::{Rect, SizeProposal};
57use teksilo_core::accessibility::AccessNodeBuilder;
58use teksilo_core::build_context::BuildContext;
59use teksilo_core::event::{EventResponse, Key, WidgetEvent};
60use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
61use teksilo_core::widget_builder::HandlerSet;
62use teksilo_core::widget_id::WidgetId;
63use teksilo_i18n::{LocalizedString, lit};
64
65use crate::primitives::{Divider, Expand, HStack, Switcher, VStack};
66
67pub use controller::StepperController;
68pub use nav::{FinishOutcome, IntoFinishOutcome};
69pub use step::{Step, StepStatus};
70pub use wizard::Wizard;
71
72use content_pane::StepPane;
73use footer::StepperFooter;
74use indicator::DEFAULT_CIRCLE_SIZE;
75use indicator_strip::{IndicatorStrip, StepMeta};
76use nav::{FinishAction, StepNav};
77
78/// Indicator-strip orientation for a [`Stepper`].
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum StepperOrientation {
81    /// Markers in a row, content below (default).
82    #[default]
83    Horizontal,
84    /// Markers in a column on the leading side, content beside.
85    Vertical,
86}
87
88/// Where the optional chrome slot (banner / sidebar) sits relative to the
89/// stepper body.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum ChromePosition {
92    /// Leading column (left in LTR). Forced to `Top` in vertical orientation.
93    #[default]
94    Leading,
95    /// Banner above the stepper body.
96    Top,
97}
98
99type StepperAction = Rc<dyn Fn(&mut EventContext, &StepperController)>;
100
101/// An embeddable multi-step flow widget. See the [module docs](self) for the
102/// data-flow pattern and a usage example.
103pub struct Stepper {
104    steps: Vec<Step>,
105    controller: Option<StepperController>,
106    orientation: StepperOrientation,
107    non_linear: bool,
108    circle_size: f32,
109    chrome: Option<Box<dyn Widget>>,
110    chrome_position: ChromePosition,
111    back_label: LocalizedString,
112    next_label: LocalizedString,
113    finish_label: LocalizedString,
114    skip_label: LocalizedString,
115    help_label: Option<LocalizedString>,
116    help_action: Option<StepperAction>,
117    cancel_label: Option<LocalizedString>,
118    cancel_action: Option<StepperAction>,
119    finish_action: Option<FinishAction>,
120    enter_advances: bool,
121    root_child_id: Option<WidgetId>,
122    tooltip_text: Option<LocalizedString>,
123    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
124    composite_tooltip_content: Option<Box<dyn Widget>>,
125}
126
127impl Default for Stepper {
128    fn default() -> Self {
129        Self::new()
130    }
131}
132
133impl Stepper {
134    /// Create an empty `Stepper`. Append steps with [`step`](Self::step) or
135    /// [`steps`](Self::steps) and provide a finish callback with
136    /// [`on_finish`](Self::on_finish).
137    pub fn new() -> Self {
138        Self {
139            steps: Vec::new(),
140            controller: None,
141            orientation: StepperOrientation::Horizontal,
142            non_linear: false,
143            circle_size: DEFAULT_CIRCLE_SIZE,
144            chrome: None,
145            chrome_position: ChromePosition::Leading,
146            back_label: lit!("Back"),
147            next_label: lit!("Next"),
148            finish_label: lit!("Finish"),
149            skip_label: lit!("Skip"),
150            help_label: None,
151            help_action: None,
152            cancel_label: None,
153            cancel_action: None,
154            finish_action: None,
155            enter_advances: true,
156            root_child_id: None,
157            tooltip_text: None,
158            rich_tooltip_source: None,
159            composite_tooltip_content: None,
160        }
161    }
162
163    /// Append a single [`Step`] definition.
164    pub fn step(mut self, step: Step) -> Self {
165        self.steps.push(step);
166        self
167    }
168
169    /// Append multiple [`Step`] definitions from an iterator.
170    pub fn steps(mut self, steps: impl IntoIterator<Item = Step>) -> Self {
171        self.steps.extend(steps);
172        self
173    }
174
175    /// Drive the stepper with an externally-held controller (for programmatic
176    /// reset / jump / introspection). If omitted, the stepper creates its own.
177    pub fn controller(mut self, controller: StepperController) -> Self {
178        self.controller = Some(controller);
179        self
180    }
181
182    /// Set the indicator-strip orientation (horizontal or vertical).
183    pub fn orientation(mut self, orientation: StepperOrientation) -> Self {
184        self.orientation = orientation;
185        self
186    }
187
188    /// Shorthand for `.orientation(StepperOrientation::Vertical)`.
189    pub fn vertical(mut self) -> Self {
190        self.orientation = StepperOrientation::Vertical;
191        self
192    }
193
194    /// Allow jumping between steps by clicking their indicators (the markers
195    /// become `Role::Tab`). Linear (default) markers are `Role::ListItem`.
196    pub fn non_linear(mut self, non_linear: bool) -> Self {
197        self.non_linear = non_linear;
198        self
199    }
200
201    /// Override the marker circle diameter (logical px).
202    pub fn circle_size(mut self, size: f32) -> Self {
203        self.circle_size = size;
204        self
205    }
206
207    /// A generic chrome widget (banner / sidebar) — the modern replacement for
208    /// QWizard's watermark pixmap.
209    ///
210    /// **It lands in the leading column by default**
211    /// ([`ChromePosition::Leading`], QWizard's watermark slot), i.e. a full
212    /// height sidebar. For a *title banner* pair it with
213    /// `.chrome_position(ChromePosition::Top)`, or the chrome renders as a
214    /// wide sidebar holding a few words.
215    pub fn chrome(mut self, chrome: impl Widget + 'static) -> Self {
216        self.chrome = Some(Box::new(chrome));
217        self
218    }
219
220    /// Choose where the optional chrome widget sits relative to the stepper
221    /// body. Forced to [`ChromePosition::Top`] when
222    /// [`orientation`](Self::orientation) is `Vertical`.
223    pub fn chrome_position(mut self, position: ChromePosition) -> Self {
224        self.chrome_position = position;
225        self
226    }
227
228    /// Override the "Back" button label. Default: "Back".
229    pub fn back_label(mut self, label: impl Into<LocalizedString>) -> Self {
230        self.back_label = label.into();
231        self
232    }
233    /// Override the "Next" button label. Default: "Next".
234    pub fn next_label(mut self, label: impl Into<LocalizedString>) -> Self {
235        self.next_label = label.into();
236        self
237    }
238    /// Override the "Finish" button label. Default: "Finish".
239    pub fn finish_label(mut self, label: impl Into<LocalizedString>) -> Self {
240        self.finish_label = label.into();
241        self
242    }
243    /// Override the "Skip" button label. Default: "Skip".
244    pub fn skip_label(mut self, label: impl Into<LocalizedString>) -> Self {
245        self.skip_label = label.into();
246        self
247    }
248
249    /// Add a Help button + callback to the footer.
250    pub fn help(
251        mut self,
252        label: impl Into<LocalizedString>,
253        action: impl Fn(&mut EventContext, &StepperController) + 'static,
254    ) -> Self {
255        self.help_label = Some(label.into());
256        self.help_action = Some(Rc::new(action));
257        self
258    }
259
260    /// Add a Cancel button + callback to the footer.
261    pub fn cancel(
262        mut self,
263        label: impl Into<LocalizedString>,
264        action: impl Fn(&mut EventContext, &StepperController) + 'static,
265    ) -> Self {
266        self.cancel_label = Some(label.into());
267        self.cancel_action = Some(Rc::new(action));
268        self
269    }
270
271    /// Called when Finish is activated on the last step. Receives the event
272    /// context and the controller (for `skipped` / `visited` introspection);
273    /// read collected values from the form signals your steps wrote.
274    ///
275    /// **The callback may refuse.** Its return value goes through the
276    /// [`IntoFinishOutcome`] bridge — `()` always succeeds, while `false`,
277    /// `Err(_)`, or [`FinishOutcome::Rejected`] keep the stepper on the last
278    /// step and mark it [`StepStatus::Error`] (a [`Wizard`] modal stays
279    /// open). This is the
280    /// Finish counterpart of [`Step::validate_on_next`] — for the case where
281    /// the commit itself can fail (disk full, name taken, server refused):
282    ///
283    /// ```ignore
284    /// .on_finish(move |ctx, _ctrl| match create_project(&name.get()) {
285    ///     Ok(()) => true,
286    ///     Err(e) => { status.set(e.to_string()); false }
287    /// })
288    /// ```
289    pub fn on_finish<R: IntoFinishOutcome>(
290        mut self,
291        action: impl Fn(&mut EventContext, &StepperController) -> R + 'static,
292    ) -> Self {
293        self.finish_action = Some(Rc::new(move |ctx, ctrl| {
294            action(ctx, ctrl).into_finish_outcome()
295        }));
296        self
297    }
298
299    /// Whether pressing <kbd>Enter</kbd> activates the footer's primary button
300    /// (Next, or Finish on the last step). Default: `true`.
301    ///
302    /// The key is handled on the **bubble** pass at the stepper root, so a
303    /// focused control that wants Enter for itself — a Button, a multi-line
304    /// editor, a list row — consumes it first and the stepper never sees it.
305    /// A single-line form field lets it through, which is where the "Enter
306    /// means Next" contract is expected. Gates apply exactly as they do to a
307    /// click: a blocked `complete_when` / `validate_on_next` refuses the same
308    /// way.
309    ///
310    /// Turn it off for a step whose body treats Enter as content in a way the
311    /// framework cannot see.
312    pub fn enter_advances(mut self, enter_advances: bool) -> Self {
313        self.enter_advances = enter_advances;
314        self
315    }
316
317    /// Attach a plain single-line tooltip to this stepper. Clears any
318    /// previously set rich or composite tooltip.
319    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
320        self.tooltip_text = Some(text.into());
321        self.rich_tooltip_source = None;
322        self.composite_tooltip_content = None;
323        self
324    }
325
326    /// Attach a rich tooltip identified by a registry key. Clears any
327    /// previously set plain or composite tooltip.
328    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
329        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
330        self.tooltip_text = None;
331        self.composite_tooltip_content = None;
332        self
333    }
334
335    /// Attach a rich tooltip with inline content. Clears any previously set
336    /// plain or composite tooltip.
337    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
338        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
339        self.tooltip_text = None;
340        self.composite_tooltip_content = None;
341        self
342    }
343
344    /// Attach a composite tooltip (arbitrary widget body). Clears any
345    /// previously set plain or rich tooltip.
346    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
347        self.composite_tooltip_content = Some(Box::new(content));
348        self.tooltip_text = None;
349        self.rich_tooltip_source = None;
350        self
351    }
352}
353
354impl std::fmt::Debug for Stepper {
355    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356        f.debug_struct("Stepper")
357            .field("steps", &self.steps.len())
358            .field("orientation", &self.orientation)
359            .field("non_linear", &self.non_linear)
360            .finish()
361    }
362}
363
364impl Widget for Stepper {
365    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
366        if self.steps.is_empty() {
367            self.root_child_id = None;
368            return Vec::new();
369        }
370
371        // Reuse a persisted controller across rebuilds so navigation state
372        // survives an ancestor-triggered rebuild. `seed_statuses` is
373        // idempotent, so re-seeding here is harmless.
374        let controller = self
375            .controller
376            .get_or_insert_with(|| StepperController::new(self.steps.len()))
377            .clone();
378        controller.seed_statuses(self.steps.iter().map(|s| s.initial_status).collect());
379
380        // Per-step visibility: seed the controller from each gate's current
381        // value, then keep it in sync. The effect is scoped to this build, so
382        // a rebuild re-registers rather than accumulating observers.
383        for (i, step) in self.steps.iter().enumerate() {
384            let Some(prop) = step.visible.clone() else {
385                continue;
386            };
387            let signal = prop.as_signal();
388            controller.set_visible(i, signal.get());
389            let c = controller.clone();
390            ctx.effect(&signal, move |visible| c.set_visible(i, *visible));
391        }
392
393        // The Next / Finish semantics, shared by the footer buttons and the
394        // Enter key so both run one code path.
395        let nav = Rc::new(StepNav::new(
396            controller.clone(),
397            self.steps.iter().map(|s| s.validate.clone()).collect(),
398            self.steps.iter().map(|s| s.complete.clone()).collect(),
399            self.finish_action.clone(),
400        ));
401
402        let panel_ids: Rc<RefCell<Vec<WidgetId>>> = Rc::new(RefCell::new(Vec::new()));
403        let indicator_ids: Rc<RefCell<Vec<WidgetId>>> = Rc::new(RefCell::new(Vec::new()));
404
405        // Pre-mount every step pane so `panel_ids` is complete on the first
406        // build (required for the indicators' `controls` and the panes'
407        // `labelled_by`). The content factory runs eagerly here.
408        let mut switcher = Switcher::new(controller.current_step_signal())
409            .capture_child_ids_into(panel_ids.clone());
410        for step in &self.steps {
411            let factory = step.content_factory.as_ref().unwrap_or_else(|| {
412                panic!(
413                    "Step \"{}\" requires .content(...) — no content factory was set",
414                    step.title.resolve_now()
415                )
416            });
417            let pane = StepPane::new(
418                step.title.clone(),
419                factory(),
420                panel_ids.clone(),
421                indicator_ids.clone(),
422            );
423            let pane_id = ctx.add(pane);
424            switcher = switcher.child_id(pane_id);
425        }
426        let switcher_id = ctx.add(switcher);
427
428        let metas: Vec<StepMeta> = self
429            .steps
430            .iter()
431            .map(|s| StepMeta {
432                title: s.title.clone(),
433                supporting_text: s.supporting_text.clone(),
434            })
435            .collect();
436        let strip_id = ctx.add(IndicatorStrip::new(
437            metas,
438            controller.clone(),
439            self.orientation,
440            self.non_linear,
441            self.circle_size,
442            indicator_ids.clone(),
443            panel_ids.clone(),
444        ));
445
446        let optional_flags: Vec<bool> = self
447            .steps
448            .iter()
449            .map(|s| s.initial_status == StepStatus::Optional)
450            .collect();
451        let footer_id = ctx.add(StepperFooter::new(
452            nav.clone(),
453            optional_flags,
454            self.back_label.clone(),
455            self.next_label.clone(),
456            self.finish_label.clone(),
457            self.skip_label.clone(),
458            self.help_label.clone(),
459            self.cancel_label.clone(),
460            self.help_action.clone(),
461            self.cancel_action.clone(),
462        ));
463
464        let content = ctx.add(Expand::new().child_id(switcher_id));
465
466        let body = match self.orientation {
467            StepperOrientation::Horizontal => ctx.add(
468                VStack::new()
469                    .spacing(12.0)
470                    .add_child(strip_id)
471                    .child(Divider::new())
472                    .add_child(content)
473                    .child(Divider::new())
474                    .add_child(footer_id),
475            ),
476            StepperOrientation::Vertical => {
477                let right = ctx.add(
478                    VStack::new()
479                        .spacing(12.0)
480                        .add_child(content)
481                        .child(Divider::new())
482                        .add_child(footer_id),
483                );
484                ctx.add(
485                    HStack::new()
486                        .spacing(20.0)
487                        .add_child(strip_id)
488                        .child(Expand::new().child_id(right)),
489                )
490            }
491        };
492
493        // Chrome slot. Vertical orientation forces the banner on top to avoid a
494        // cramped three-column layout.
495        let root = if let Some(chrome) = self.chrome.take() {
496            let chrome_id = ctx.add_boxed(chrome);
497            let on_top = matches!(self.chrome_position, ChromePosition::Top)
498                || matches!(self.orientation, StepperOrientation::Vertical);
499            if on_top {
500                ctx.add(
501                    VStack::new()
502                        .spacing(12.0)
503                        .add_child(chrome_id)
504                        .child(Expand::new().child_id(body)),
505                )
506            } else {
507                ctx.add(
508                    HStack::new()
509                        .spacing(16.0)
510                        .add_child(chrome_id)
511                        .child(Expand::new().child_id(body)),
512                )
513            }
514        } else {
515            body
516        };
517
518        self.root_child_id = Some(root);
519
520        // Enter activates the primary footer button. Bubble pass (not
521        // preview), so a focused control that owns Enter — a Button, a
522        // multi-line editor — consumes it before the stepper ever sees it;
523        // only an Enter nothing else claimed reaches here.
524        if self.enter_advances {
525            let nav = nav.clone();
526            ctx.apply_self_handlers(HandlerSet::new().on_key(move |event, ctx| match event {
527                WidgetEvent::KeyUp {
528                    key: Key::Enter,
529                    modifiers,
530                } if !modifiers.ctrl() && !modifiers.alt() && !modifiers.super_key() => {
531                    nav.activate_primary(ctx);
532                    EventResponse::Handled
533                }
534                // Swallow the matching KeyDown so it cannot be interpreted
535                // twice by an ancestor (e.g. a Dialog's default button).
536                WidgetEvent::KeyDown {
537                    key: Key::Enter,
538                    modifiers,
539                    ..
540                } if !modifiers.ctrl() && !modifiers.alt() && !modifiers.super_key() => {
541                    EventResponse::Handled
542                }
543                _ => EventResponse::Ignored,
544            }));
545        }
546
547        if let Some(content) = self.composite_tooltip_content.take() {
548            let delay = ctx.theme().motion.tooltip_delay_heavy;
549            crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
550        } else if let Some(source) = self.rich_tooltip_source.clone() {
551            let delay = ctx.theme().motion.tooltip_delay;
552            crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
553        } else if let Some(text) = self.tooltip_text.clone() {
554            let delay = ctx.theme().motion.tooltip_delay;
555            crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
556        }
557
558        vec![root]
559    }
560
561    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
562        self.root_child_id
563            .and_then(|id| ctx.child_size(id, proposal))
564            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
565            .into()
566    }
567
568    fn place_children(
569        &self,
570        bounds: Rect,
571        _proposal: SizeProposal,
572        children: &mut [WidgetPlacement],
573        _ctx: &LayoutContext,
574    ) {
575        for child in children.iter_mut() {
576            child.origin = bounds.origin();
577            child.size = bounds.size();
578        }
579    }
580
581    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
582        builder.set_role(teksilo_core::accesskit::Role::Group);
583        builder.set_name(teksilo_i18n::tr_widget!(a11y_stepper_content_name()).resolve_now());
584    }
585
586    fn children(&self) -> Vec<WidgetId> {
587        self.root_child_id.into_iter().collect()
588    }
589}