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}