Skip to main content

teksilo_widgets/stepper/
controller.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`StepperController`] — a shared, cloneable handle that drives a
5//! [`Stepper`](crate::stepper::Stepper) and lets app code reset / jump /
6//! introspect it from the outside.
7//!
8//! Mirrors the `SceneModel = Rc<RefCell<…>>` pattern: cloning a controller
9//! produces a second handle to the **same** state, so a toolbar button and the
10//! stepper itself share one source of truth. Every mutator takes `&self`,
11//! mutates the inner state, drops the borrow, then writes the reactive signals
12//! — so a signal observer (the bound stepper rebuild) never re-borrows the
13//! controller mid-mutation.
14
15use std::cell::RefCell;
16use std::rc::Rc;
17
18use teksilo_core::signal::Signal;
19
20use super::step::StepStatus;
21
22struct StepperState {
23    step_count: usize,
24    statuses: Vec<StepStatus>,
25    /// The statuses the stepper seeded from its `Step` list. `reset()`
26    /// restores these rather than blanket `Upcoming`, so a step declared
27    /// `Disabled` / `Optional` keeps that character across a reset.
28    initial_statuses: Vec<StepStatus>,
29    /// Per-step visibility, driven by [`super::Step::visible_when`] (or
30    /// [`StepperController::set_visible`]). An invisible step is skipped by
31    /// navigation and hidden from the indicator strip.
32    visible: Vec<bool>,
33    /// Back-stack of previously-visited step indices (most recent last),
34    /// not including the current step. `back()` pops this — so it returns
35    /// to where the user actually came from, even after a non-linear jump.
36    visit_history: Vec<usize>,
37    visited: Vec<bool>,
38    skipped: Vec<bool>,
39    /// `true` once [`StepperController::seed_statuses`] has run, so a stepper
40    /// rebuild does not wipe accumulated progress.
41    seeded: bool,
42}
43
44impl StepperState {
45    /// A step is reachable when it is visible and not `Disabled` — the two
46    /// ways an app takes a step out of the flow.
47    fn reachable(&self, idx: usize) -> bool {
48        self.visible.get(idx).copied().unwrap_or(false)
49            && !matches!(self.statuses.get(idx), Some(StepStatus::Disabled))
50    }
51
52    fn next_reachable(&self, from: usize) -> Option<usize> {
53        ((from + 1)..self.step_count).find(|&i| self.reachable(i))
54    }
55
56    fn first_reachable(&self) -> Option<usize> {
57        (0..self.step_count).find(|&i| self.reachable(i))
58    }
59}
60
61/// Shared handle controlling a [`Stepper`](crate::stepper::Stepper).
62#[derive(Clone)]
63pub struct StepperController {
64    inner: Rc<RefCell<StepperState>>,
65    /// Active step index — the stepper's `Switcher` and indicator strip bind
66    /// to this.
67    current: Signal<usize>,
68    /// Bumped on every structural mutation; the stepper binds it at
69    /// `BindingLevel::Rebuild` so external `go_to`/`set_status`/`reset`
70    /// re-derive the indicator strip and footer.
71    version: Signal<u64>,
72}
73
74impl StepperController {
75    /// A controller for a stepper with `step_count` steps, starting at step 0.
76    pub fn new(step_count: usize) -> Self {
77        let mut visited = vec![false; step_count];
78        if step_count > 0 {
79            visited[0] = true;
80        }
81        Self {
82            inner: Rc::new(RefCell::new(StepperState {
83                step_count,
84                statuses: vec![StepStatus::Upcoming; step_count],
85                initial_statuses: vec![StepStatus::Upcoming; step_count],
86                visible: vec![true; step_count],
87                visit_history: Vec::new(),
88                visited,
89                skipped: vec![false; step_count],
90                seeded: false,
91            })),
92            current: Signal::new(0),
93            version: Signal::new(0),
94        }
95    }
96
97    /// Seed the per-step statuses (called by the stepper from its `Step`
98    /// list). Marks the active step `Active`. Idempotent: a no-op after the
99    /// first call, so a stepper rebuild never wipes accumulated progress.
100    pub(crate) fn seed_statuses(&self, statuses: Vec<StepStatus>) {
101        {
102            let mut st = self.inner.borrow_mut();
103            if st.seeded {
104                return;
105            }
106            if statuses.len() == st.step_count {
107                st.initial_statuses = statuses.clone();
108                st.statuses = statuses;
109            }
110            st.seeded = true;
111        }
112        let cur = self.current.get();
113        self.mark_active(cur);
114    }
115
116    fn bump(&self) {
117        self.version.set(self.version.get().wrapping_add(1));
118    }
119
120    /// Set `idx` to `Active` and demote any other previously-active step.
121    fn mark_active(&self, idx: usize) {
122        {
123            let mut st = self.inner.borrow_mut();
124            for (i, s) in st.statuses.iter_mut().enumerate() {
125                if *s == StepStatus::Active && i != idx {
126                    // A step we are leaving becomes Complete unless it was an
127                    // error/skip; keep terminal states sticky.
128                    *s = StepStatus::Complete;
129                }
130            }
131            if let Some(s) = st.statuses.get_mut(idx) {
132                if !matches!(*s, StepStatus::Error | StepStatus::Disabled) {
133                    *s = StepStatus::Active;
134                }
135            }
136        }
137        self.bump();
138    }
139
140    /// Advance to the next **reachable** step, recording the current one on
141    /// the back-stack. Invisible ([`Step::visible_when`](super::Step::visible_when))
142    /// and [`StepStatus::Disabled`] steps are stepped over; a no-op when none
143    /// remains.
144    pub fn next(&self) {
145        let cur = self.current.get();
146        let dest = {
147            let mut st = self.inner.borrow_mut();
148            let Some(dest) = st.next_reachable(cur) else {
149                return;
150            };
151            st.visit_history.push(cur);
152            if let Some(v) = st.visited.get_mut(dest) {
153                *v = true;
154            }
155            dest
156        };
157        self.current.set(dest);
158        self.mark_active(dest);
159    }
160
161    /// Mark the current (optional) step skipped, then advance like
162    /// [`next`](Self::next).
163    pub fn skip(&self) {
164        let cur = self.current.get();
165        {
166            let mut st = self.inner.borrow_mut();
167            if let Some(sk) = st.skipped.get_mut(cur) {
168                *sk = true;
169            }
170            if let Some(s) = st.statuses.get_mut(cur) {
171                *s = StepStatus::Skipped;
172            }
173        }
174        self.next();
175    }
176
177    /// Return to the most recently visited **reachable** step (the back-stack
178    /// top). Entries that became unreachable meanwhile are popped and skipped.
179    /// No-op on an empty stack.
180    pub fn back(&self) {
181        let dest = {
182            let mut st = self.inner.borrow_mut();
183            loop {
184                match st.visit_history.pop() {
185                    Some(i) if st.reachable(i) => break Some(i),
186                    Some(_) => continue,
187                    None => break None,
188                }
189            }
190        };
191        if let Some(dest) = dest {
192            self.current.set(dest);
193            self.mark_active(dest);
194        }
195    }
196
197    /// Jump to step `idx` (non-linear), recording the current step on the
198    /// back-stack so [`back`](Self::back) returns here. A no-op when `idx` is
199    /// out of range or not [reachable](Self::is_reachable).
200    pub fn go_to(&self, idx: usize) {
201        let cur = self.current.get();
202        {
203            let mut st = self.inner.borrow_mut();
204            if idx == cur || !st.reachable(idx) {
205                return;
206            }
207            st.visit_history.push(cur);
208            if let Some(v) = st.visited.get_mut(idx) {
209                *v = true;
210            }
211        }
212        self.current.set(idx);
213        self.mark_active(idx);
214    }
215
216    /// Reset to the first reachable step: clears the back-stack, restores the
217    /// statuses the stepper was declared with (so a `Disabled` / `Optional`
218    /// step keeps its character), and clears visited/skipped flags. Per-step
219    /// visibility is app-owned and left untouched.
220    pub fn reset(&self) {
221        let dest = {
222            let mut st = self.inner.borrow_mut();
223            let n = st.step_count;
224            st.statuses = st.initial_statuses.clone();
225            st.visit_history.clear();
226            st.visited = vec![false; n];
227            st.skipped = vec![false; n];
228            let dest = st.first_reachable().unwrap_or(0);
229            if let Some(v) = st.visited.get_mut(dest) {
230                *v = true;
231            }
232            dest
233        };
234        self.current.set(dest);
235        self.mark_active(dest);
236    }
237
238    /// Override a step's [`StepStatus`] (e.g. mark it `Error` after async
239    /// validation). Setting [`StepStatus::Disabled`] takes the step out of the
240    /// flow — [`next`](Self::next) / [`go_to`](Self::go_to) skip it — but does
241    /// **not** move off it if it is the active step.
242    pub fn set_status(&self, idx: usize, status: StepStatus) {
243        {
244            let mut st = self.inner.borrow_mut();
245            if let Some(s) = st.statuses.get_mut(idx) {
246                *s = status;
247            }
248        }
249        self.bump();
250    }
251
252    /// Show or hide step `idx`. A hidden step is skipped by
253    /// [`next`](Self::next) / [`back`](Self::back) / [`go_to`](Self::go_to)
254    /// and drops out of the indicator strip — the branching-wizard shape
255    /// ("this step only if you chose X") without maintaining two step lists.
256    ///
257    /// Usually driven declaratively by
258    /// [`Step::visible_when`](super::Step::visible_when); this is the
259    /// imperative twin. Hiding the *active* step does not navigate away from
260    /// it — hide steps the user has not reached yet.
261    pub fn set_visible(&self, idx: usize, visible: bool) {
262        {
263            let mut st = self.inner.borrow_mut();
264            match st.visible.get_mut(idx) {
265                Some(v) if *v == visible => return,
266                Some(v) => *v = visible,
267                None => return,
268            }
269        }
270        self.bump();
271    }
272
273    // ── queries ────────────────────────────────────────────────────────────
274
275    pub fn current(&self) -> usize {
276        self.current.get()
277    }
278
279    pub fn status(&self, idx: usize) -> StepStatus {
280        self.inner
281            .borrow()
282            .statuses
283            .get(idx)
284            .copied()
285            .unwrap_or_default()
286    }
287
288    /// `true` if step `idx` has ever been the active step.
289    pub fn visited(&self, idx: usize) -> bool {
290        self.inner
291            .borrow()
292            .visited
293            .get(idx)
294            .copied()
295            .unwrap_or(false)
296    }
297
298    /// `true` if step `idx` was skipped via [`skip`](Self::skip).
299    pub fn skipped(&self, idx: usize) -> bool {
300        self.inner
301            .borrow()
302            .skipped
303            .get(idx)
304            .copied()
305            .unwrap_or(false)
306    }
307
308    /// `true` if step `idx` is visible (see [`set_visible`](Self::set_visible)).
309    pub fn is_visible(&self, idx: usize) -> bool {
310        self.inner
311            .borrow()
312            .visible
313            .get(idx)
314            .copied()
315            .unwrap_or(false)
316    }
317
318    /// `true` if step `idx` participates in the flow — visible **and** not
319    /// [`StepStatus::Disabled`].
320    pub fn is_reachable(&self, idx: usize) -> bool {
321        self.inner.borrow().reachable(idx)
322    }
323
324    /// The next reachable step after `from`, if any.
325    pub fn next_reachable(&self, from: usize) -> Option<usize> {
326        self.inner.borrow().next_reachable(from)
327    }
328
329    /// `true` if [`next`](Self::next) would move — i.e. the active step is not
330    /// the last reachable one. The footer shows Next when this holds and
331    /// Finish when it does not.
332    pub fn has_next(&self) -> bool {
333        let cur = self.current.get();
334        self.inner.borrow().next_reachable(cur).is_some()
335    }
336
337    pub fn step_count(&self) -> usize {
338        self.inner.borrow().step_count
339    }
340
341    /// `true` if there is a previously-visited, still-reachable step to
342    /// return to.
343    pub fn can_back(&self) -> bool {
344        let st = self.inner.borrow();
345        st.visit_history.iter().any(|&i| st.reachable(i))
346    }
347
348    // ── reactive surface ─────────────────────────────────────────────────────
349
350    /// The active-step signal — the stepper's `Switcher` and indicators bind
351    /// to it.
352    pub fn current_step_signal(&self) -> Signal<usize> {
353        self.current.clone()
354    }
355
356    /// Bumped on every structural mutation; bind at `BindingLevel::Rebuild`.
357    pub fn version_signal(&self) -> Signal<u64> {
358        self.version.clone()
359    }
360}
361
362impl std::fmt::Debug for StepperController {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        f.debug_struct("StepperController")
365            .field("current", &self.current.get())
366            .field("step_count", &self.step_count())
367            .finish()
368    }
369}