Skip to main content

teksilo_widgets/stepper/
nav.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`StepNav`] — the Next / Finish semantics shared by the footer buttons and
5//! the Enter-key shortcut, plus [`FinishOutcome`]: the contract that lets an
6//! `on_finish` callback *refuse* to complete the flow.
7
8use std::cell::RefCell;
9use std::rc::Rc;
10
11use teksilo_core::signal::{Prop, Signal};
12use teksilo_core::widget::EventContext;
13use teksilo_core::widget_id::WidgetId;
14
15use super::controller::StepperController;
16use super::step::{StepStatus, StepValidator};
17
18/// What an [`on_finish`](super::Stepper::on_finish) callback decided.
19///
20/// `Finish` is the mirror of [`Step::validate_on_next`](super::Step::validate_on_next):
21/// the last step gets to say "no". Returning [`Rejected`](Self::Rejected)
22/// keeps the stepper on the last step, marks it [`StepStatus::Error`], and — in
23/// a [`Wizard`](super::Wizard) — leaves the modal open.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum FinishOutcome {
26    /// The flow completed. The last step is marked `Complete`; a `Wizard`
27    /// modal dismisses itself.
28    Finished,
29    /// The finish attempt failed (disk full, name taken, server refused). The
30    /// stepper stays put and marks the step `Error`; a `Wizard` modal stays
31    /// open so the user can correct the input and retry.
32    Rejected,
33}
34
35/// Return-type bridge for [`Stepper::on_finish`](super::Stepper::on_finish) /
36/// [`Wizard::on_finish`](super::Wizard::on_finish) callbacks, so the same
37/// setter accepts a callback that cannot fail and one that can.
38///
39/// | callback returns | outcome |
40/// | --- | --- |
41/// | `()` | `Finished` (finishing always succeeds) |
42/// | `bool` | `true` → `Finished`, `false` → `Rejected` |
43/// | `Result<T, E>` | `Ok` → `Finished`, `Err` → `Rejected` |
44/// | [`FinishOutcome`] | itself |
45pub trait IntoFinishOutcome {
46    fn into_finish_outcome(self) -> FinishOutcome;
47}
48
49impl IntoFinishOutcome for () {
50    fn into_finish_outcome(self) -> FinishOutcome {
51        FinishOutcome::Finished
52    }
53}
54
55impl IntoFinishOutcome for bool {
56    fn into_finish_outcome(self) -> FinishOutcome {
57        if self {
58            FinishOutcome::Finished
59        } else {
60            FinishOutcome::Rejected
61        }
62    }
63}
64
65impl IntoFinishOutcome for FinishOutcome {
66    fn into_finish_outcome(self) -> FinishOutcome {
67        self
68    }
69}
70
71impl<T, E> IntoFinishOutcome for Result<T, E> {
72    fn into_finish_outcome(self) -> FinishOutcome {
73        match self {
74            Ok(_) => FinishOutcome::Finished,
75            Err(_) => FinishOutcome::Rejected,
76        }
77    }
78}
79
80pub(crate) type FinishAction = Rc<dyn Fn(&mut EventContext, &StepperController) -> FinishOutcome>;
81
82/// The per-step gates + actions the footer and the Enter shortcut both need.
83///
84/// Built once by [`Stepper::build`](super::Stepper) and shared as an `Rc`, so
85/// pressing Enter on a step form and clicking Next run the *same* code path
86/// (validators, completion gate, status transitions, focus hand-off).
87pub(crate) struct StepNav {
88    controller: StepperController,
89    validators: Vec<Option<StepValidator>>,
90    completion: Vec<Option<Prop<bool>>>,
91    finish_action: Option<FinishAction>,
92    /// Filled in by the footer's build; the Enter path reuses them so focus
93    /// follows the flow instead of being stranded on the dormant pane.
94    next_focus: RefCell<Option<WidgetId>>,
95    finish_focus: RefCell<Option<WidgetId>>,
96}
97
98impl StepNav {
99    pub(crate) fn new(
100        controller: StepperController,
101        validators: Vec<Option<StepValidator>>,
102        completion: Vec<Option<Prop<bool>>>,
103        finish_action: Option<FinishAction>,
104    ) -> Self {
105        Self {
106            controller,
107            validators,
108            completion,
109            finish_action,
110            next_focus: RefCell::new(None),
111            finish_focus: RefCell::new(None),
112        }
113    }
114
115    pub(crate) fn controller(&self) -> &StepperController {
116        &self.controller
117    }
118
119    pub(crate) fn set_focus_targets(&self, next: WidgetId, finish: WidgetId) {
120        *self.next_focus.borrow_mut() = Some(next);
121        *self.finish_focus.borrow_mut() = Some(finish);
122    }
123
124    /// The step's reactive completion gate — `true` when nothing blocks Next
125    /// (a step without `complete_when` is always open).
126    pub(crate) fn gate_open(&self, idx: usize) -> bool {
127        self.completion
128            .get(idx)
129            .and_then(|c| c.as_ref())
130            .map(|p| p.get())
131            .unwrap_or(true)
132    }
133
134    /// Per-step completion signals, in declaration order — the footer wires
135    /// them into one `flat_map` that follows the *active* step's gate.
136    pub(crate) fn completion_signals(&self) -> Vec<Signal<bool>> {
137        self.completion
138            .iter()
139            .map(|c| {
140                c.as_ref()
141                    .map(|p| p.as_signal())
142                    .unwrap_or_else(|| Signal::new(true))
143            })
144            .collect()
145    }
146
147    /// Run the imperative `validate_on_next` hook; `true` when it passes or
148    /// there is none.
149    fn validates(&self, idx: usize) -> bool {
150        match self.validators.get(idx) {
151            Some(Some(v)) => v(),
152            _ => true,
153        }
154    }
155
156    /// Move focus to whichever primary button is shown for the *current*
157    /// step, so a navigation that hides the focused control (Back / Skip /
158    /// Enter) does not strand focus on a dormant pane.
159    pub(crate) fn focus_primary(&self, ctx: &mut EventContext) {
160        let target = if self.controller.has_next() {
161            *self.next_focus.borrow()
162        } else {
163            *self.finish_focus.borrow()
164        };
165        if let Some(t) = target {
166            ctx.request_focus(t);
167        }
168    }
169
170    /// Next: gate → validator → advance. `false` when it refused to move.
171    pub(crate) fn advance(&self, ctx: &mut EventContext) -> bool {
172        let i = self.controller.current();
173        if !self.gate_open(i) {
174            return false;
175        }
176        if !self.validates(i) {
177            self.controller.set_status(i, StepStatus::Error);
178            return false;
179        }
180        // Clear any prior Error and mark done before advancing (`mark_active`
181        // only auto-completes a step left in the Active state, not one stuck
182        // in Error).
183        self.controller.set_status(i, StepStatus::Complete);
184        self.controller.next();
185        self.focus_primary(ctx);
186        true
187    }
188
189    /// Finish: gate → validator → `on_finish`. The callback's
190    /// [`FinishOutcome`] decides whether the last step lands on `Complete` or
191    /// `Error`; a `Rejected` finish leaves the stepper exactly where it was.
192    pub(crate) fn finish(&self, ctx: &mut EventContext) -> FinishOutcome {
193        let i = self.controller.current();
194        if !self.gate_open(i) {
195            return FinishOutcome::Rejected;
196        }
197        if !self.validates(i) {
198            self.controller.set_status(i, StepStatus::Error);
199            return FinishOutcome::Rejected;
200        }
201        let outcome = match &self.finish_action {
202            Some(action) => action(ctx, &self.controller),
203            None => FinishOutcome::Finished,
204        };
205        match outcome {
206            FinishOutcome::Finished => self.controller.set_status(i, StepStatus::Complete),
207            FinishOutcome::Rejected => self.controller.set_status(i, StepStatus::Error),
208        }
209        outcome
210    }
211
212    /// Whatever the footer's primary button would do right now — Next while a
213    /// reachable step remains, Finish on the last one. Drives the Enter key.
214    pub(crate) fn activate_primary(&self, ctx: &mut EventContext) -> bool {
215        if self.controller.has_next() {
216            self.advance(ctx)
217        } else {
218            self.finish(ctx) == FinishOutcome::Finished
219        }
220    }
221}
222
223impl std::fmt::Debug for StepNav {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        f.debug_struct("StepNav")
226            .field("steps", &self.validators.len())
227            .finish()
228    }
229}