Skip to main content

frus_widgets/
steps.rs

1//! [`Steps`]: a **step indicator** — the numbered breadcrumb of a multi-step form
2//! or wizard, in the Material `Stepper` style.
3//!
4//! A row of round numbered markers joined by connectors, each in one of three
5//! states: **done** (a tick, accent), **current** (the number, accent), **upcoming**
6//! (the number, a bordered surface). A label sits under each marker.
7//!
8//! The widget is **purely visual**: navigation (Next/Previous) and per-step
9//! validation stay in the application (one [`crate::form::Form`] per step, buttons
10//! that change the current step). Since the name `Stepper` is already taken by the
11//! −/value/+ numeric picker, this indicator is called `Steps`.
12
13use frus_core::{
14    Color, Point, Rect, ResolvedTextStyle, Role, Scene, SemanticsProperties, TextStyle,
15};
16use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
17
18use crate::flex::Flex;
19use crate::icons::Icons;
20use crate::interaction::Status;
21use crate::theme::Theme;
22use crate::widget::Widget;
23
24/// The diameter of a (round) marker.
25const MARKER_D: f32 = 28.0;
26/// The radius of a marker.
27const R: f32 = MARKER_D / 2.0;
28/// The marker → label gap.
29const LABEL_GAP: f32 = 8.0;
30/// The figure inside a marker. The reference writes this one out too — a step's index is
31/// 12 px there, with the step's own style as the way to change it.
32const NUM_SIZE: f32 = 12.0;
33/// Total height of the indicator (marker + label).
34const HEIGHT: f32 = 56.0;
35
36/// A step's label: what the caller said, else what the theme says, else the reference's —
37/// a step is titled in `bodyLarge`.
38///
39/// The reference sets that title *beside* the marker and this one sits *under* it: the
40/// placement is ours, the role is read rather than invented, and an application that wants
41/// a caption under its markers says so on the widget or in the theme.
42///
43/// **Resolved once** so that the number the bar is measured with is the number the glyphs
44/// are drawn at. Resolving is the single place the reader's font setting is applied
45/// (milestone 403).
46fn label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
47    over.or(theme.and_then(|t| t.widgets.steps.label_text_style))
48        .unwrap_or_else(|| crate::theme::type_scale(theme).body_large)
49        .resolved()
50}
51
52/// The figure inside a marker.
53///
54/// Untold it is [`ResolvedTextStyle::exact`] — **geometry, not type**: it sits inside a
55/// circle of a fixed `MARKER_D`, and a figure that grew with the reader's setting would
56/// leave it. A style that *is* told is resolved like any other, because a caller who names
57/// this one has taken that decision back.
58fn index_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
59    match over.or(theme.and_then(|t| t.widgets.steps.index_text_style)) {
60        Some(style) => style.resolved(),
61        None => ResolvedTextStyle::exact(NUM_SIZE),
62    }
63}
64
65/// The bar's height: the constant, unless the labels the reader asked to enlarge no longer
66/// fit under the markers.
67fn bar_height(label: &ResolvedTextStyle) -> f32 {
68    HEIGHT.max(MARKER_D + LABEL_GAP + label.line_height() + 4.0)
69}
70
71/// The step indicator of a multi-step form.
72///
73/// ```
74/// use frus_widgets::Steps;
75/// // Three steps, the second in progress (so the first counts as "done").
76/// let steps: Steps<()> = Steps::new(["Account", "Profile", "Review"]).current(1);
77/// ```
78pub struct Steps<Msg = crate::callback::Callback> {
79    labels: Vec<String>,
80    current: usize,
81    /// Overridden accent color; `None` = the theme's `primary`.
82    color: Option<Color>,
83    /// An **explicit** per-step "done" mask (validity). Empty → the default rule
84    /// (`i < current`, see [`completed`](Self::completed)).
85    completed: Vec<bool>,
86    label_text_style: Option<TextStyle>,
87    index_text_style: Option<TextStyle>,
88    /// Empty, or **one** row of clickable hotspots laid over the markers when
89    /// [`on_tap`](Self::on_tap) is supplied.
90    children: Vec<Box<dyn Widget<Msg>>>,
91}
92
93impl<Msg: Clone + 'static> Steps<Msg> {
94    /// Creates an indicator from the step labels; the current step is the first one.
95    pub fn new(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
96        Self {
97            labels: labels.into_iter().map(Into::into).collect(),
98            current: 0,
99            color: None,
100            completed: Vec::new(),
101            label_text_style: None,
102            index_text_style: None,
103            children: Vec::new(),
104        }
105    }
106
107    /// The captions under the markers, over the theme's and the reference's.
108    #[must_use]
109    pub fn label_text_style(mut self, style: TextStyle) -> Self {
110        self.label_text_style = Some(style);
111        self
112    }
113
114    /// The figures inside the markers, over the theme's and the framework's.
115    ///
116    /// Naming one also puts it back **under the reader's font setting**. Untold, the figure
117    /// is geometry rather than type — it sits inside a circle of a fixed diameter, and one
118    /// that grew with the reader's setting would leave it. A caller who names this style has
119    /// taken that decision back.
120    #[must_use]
121    pub fn index_text_style(mut self, style: TextStyle) -> Self {
122        self.index_text_style = Some(style);
123        self
124    }
125
126    /// Explicitly marks the **done** steps (a tick) with one flag per step — typically each
127    /// step's **validity**, rather than position alone. Without this call, a step counts as
128    /// "done" if it precedes the current one (`i < current`).
129    pub fn completed(mut self, flags: impl IntoIterator<Item = bool>) -> Self {
130        self.completed = flags.into_iter().collect();
131        self
132    }
133
134    /// Sets the **current** step (the earlier ones are "done", the later ones "upcoming").
135    /// Clamped to the last index.
136    pub fn current(mut self, index: usize) -> Self {
137        self.current = index.min(self.labels.len().saturating_sub(1));
138        self
139    }
140
141    /// Overrides the accent color (done and current markers + the connectors crossed).
142    pub fn color(mut self, color: Color) -> Self {
143        self.color = Some(color);
144        self
145    }
146
147    /// Makes the markers **clickable**: clicking step `i`'s marker emits `on_tap(i)` (to jump
148    /// there — typically a step already visited). Lays a row of transparent clickable zones
149    /// **exactly** over the markers (a `SpaceBetween` distribution of marker-sized boxes),
150    /// without changing the rendering.
151    pub fn on_tap(mut self, on_tap: impl Fn(usize) -> Msg) -> Self {
152        let mut row: Flex<Msg> = Flex::row().justify(Justify::SpaceBetween);
153        for (i, label) in self.labels.iter().enumerate() {
154            row = row.child(Hotspot {
155                label: label.clone(),
156                message: on_tap(i),
157            });
158        }
159        self.children = vec![Box::new(row)];
160        self
161    }
162}
163
164impl<Msg> Steps<Msg> {
165    fn sizing(&self, theme: Option<&Theme>) -> Style {
166        let label = label_style(self.label_text_style, theme);
167        Style {
168            height: Dimension::Length(bar_height(&label)),
169            // Any row of hotspots occupies the top, the markers' band.
170            flex_direction: FlexDirection::Column,
171            align: Align::Stretch,
172            ..Default::default()
173        }
174    }
175
176    /// Is step `i` **done**? The explicit mask if one is supplied, otherwise `i < current`.
177    fn is_done(&self, i: usize) -> bool {
178        if self.completed.is_empty() {
179            i < self.current
180        } else {
181            self.completed.get(i).copied().unwrap_or(false)
182        }
183    }
184
185    /// The x of marker `i`'s centre within `bounds` (markers spread from edge to edge).
186    fn center_x(&self, bounds: Rect, i: usize) -> f32 {
187        let n = self.labels.len();
188        if n <= 1 {
189            bounds.x + bounds.width * 0.5
190        } else {
191            bounds.x + R + i as f32 * (bounds.width - 2.0 * R) / (n as f32 - 1.0)
192        }
193    }
194}
195
196impl<Msg: Clone> Widget<Msg> for Steps<Msg> {
197    /// It asks to **fill the width it is offered** rather than declaring one — see
198    /// [`Widget::fill_axes`]. A `width: 100%` resolves against the parent's *resolved*
199    /// width, which a parent that shrink-wraps does not have yet.
200    fn style(&self) -> Style {
201        self.sizing(None)
202    }
203
204    fn style_themed(&self, theme: &Theme) -> Style {
205        self.sizing(Some(theme))
206    }
207
208    /// The width it was **offered**, not the width its parent came out at.
209    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
210        crate::widget::FillAxes::WIDTH
211    }
212
213    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
214        &self.children
215    }
216
217    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
218        let n = self.labels.len();
219        if n == 0 {
220            return;
221        }
222        let o = status.opacity;
223        let accent = self.color.unwrap_or(theme.primary);
224        let cy = bounds.y + R;
225
226        // Connectors (under the markers): crossed (accent) up to the current step, else border.
227        for i in 0..n.saturating_sub(1) {
228            let x0 = self.center_x(bounds, i) + R;
229            let x1 = self.center_x(bounds, i + 1) - R;
230            let col = if self.is_done(i) {
231                accent
232            } else {
233                theme.border
234            };
235            let rect = Rect::new(x0, cy - 1.0, (x1 - x0).max(0.0), 2.0);
236            scene.draw_rect(rect, col.fade(o), 0.0, 0.0, Color::TRANSPARENT);
237        }
238
239        // Markers + numbers/ticks + labels.
240        for i in 0..n {
241            let cx = self.center_x(bounds, i);
242            let rect = Rect::new(cx - R, cy - R, MARKER_D, MARKER_D);
243            let current = i == self.current;
244            // The current step shows its number (even when valid); the others show a tick
245            // if done (validity), otherwise their number.
246            let completed = !current && self.is_done(i);
247
248            let (fill, bw, bc) = if completed || current {
249                (accent, 0.0, Color::TRANSPARENT)
250            } else {
251                (theme.surface, 1.5, theme.border)
252            };
253            scene.draw_rect(rect, fill.fade(o), R, bw, bc.fade(o));
254
255            if completed {
256                // A tick (a centred 16 px icon) on an accent background.
257                let path = Icons::CHECK.placed(16.0, cx - 8.0, cy - 8.0, theme.direction);
258                scene.fill_path(&path, theme.on_primary.fade(o));
259            } else {
260                let num = (i + 1).to_string();
261                let num_style = index_style(self.index_text_style, Some(theme));
262                let m = frus_text::measure_resolved(&num, &num_style);
263                let color = if current {
264                    theme.on_primary
265                } else {
266                    theme.on_surface
267                };
268                let p = Point::new(cx - m.width * 0.5, cy - m.height * 0.5);
269                scene.text(p, num, &num_style, color.fade(o));
270            }
271
272            // The label under the marker, centred; dimmed away from the current step.
273            let label = &self.labels[i];
274            let label_s = label_style(self.label_text_style, Some(theme));
275            let lm = frus_text::measure_resolved(label, &label_s);
276            let alpha = if current { o } else { 0.6 * o };
277            // Centred on the marker, **kept inside the bar**. The first and last markers
278            // sit one radius from the edges, so a label wider than `MARKER_D` hangs off
279            // the end of the widget — which it always could, and which a label read at
280            // the reference's `bodyLarge` rather than at a private 12 px makes ordinary.
281            let lx = (cx - lm.width * 0.5)
282                .clamp(bounds.x, (bounds.x + bounds.width - lm.width).max(bounds.x));
283            let p = Point::new(lx, bounds.y + MARKER_D + LABEL_GAP);
284            scene.text(p, label.clone(), &label_s, theme.on_surface.fade(alpha));
285        }
286    }
287
288    fn on_click(&self) -> Option<Msg> {
289        None
290    }
291}
292
293/// A **transparent** clickable zone the size of a marker, laid over it when
294/// [`Steps::on_tap`] is used: it draws nothing but captures the click (and the keyboard
295/// focus) to jump to the matching step.
296struct Hotspot<Msg> {
297    label: String,
298    message: Msg,
299}
300
301impl<Msg: Clone> Widget<Msg> for Hotspot<Msg> {
302    fn style(&self) -> Style {
303        Style {
304            width: Dimension::Length(MARKER_D),
305            height: Dimension::Length(MARKER_D),
306            ..Default::default()
307        }
308    }
309
310    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
311        &[]
312    }
313
314    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
315
316    fn on_click(&self) -> Option<Msg> {
317        Some(self.message.clone())
318    }
319
320    fn focusable(&self) -> bool {
321        true
322    }
323
324    fn semantics(&self) -> Option<SemanticsProperties> {
325        Some(
326            SemanticsProperties::new(Role::Button)
327                .label(self.label.clone())
328                .clickable(),
329        )
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use frus_core::Primitive;
337
338    fn paint_steps(steps: &Steps<()>) -> Vec<Primitive> {
339        let mut scene = Scene::new();
340        Widget::<()>::paint(
341            steps,
342            Rect::new(0.0, 0.0, 400.0, HEIGHT),
343            Status::default(),
344            &Theme::default(),
345            &mut scene,
346        );
347        scene.primitives().to_vec()
348    }
349
350    #[test]
351    fn current_is_clamped_to_last() {
352        let steps = Steps::<()>::new(["A", "B", "C"]).current(9);
353        assert_eq!(steps.current, 2);
354        assert_eq!(Steps::<()>::new(Vec::<String>::new()).current(3).current, 0);
355    }
356
357    #[test]
358    fn markers_reflect_progress() {
359        // 4 steps, the 3rd (index 2) current: 0 and 1 done; 2 current; 3 upcoming.
360        let prims = paint_steps(&Steps::<()>::new(["A", "B", "C", "D"]).current(2));
361        let has_text = |t: &str| {
362            prims
363                .iter()
364                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
365        };
366        // Done → ticks (no number); current → "3"; upcoming → "4".
367        assert!(
368            has_text("3") && has_text("4"),
369            "the numbers of the current and upcoming steps"
370        );
371        assert!(
372            !has_text("1") && !has_text("2"),
373            "the done steps show a tick"
374        );
375        // One tick (a filled path) per done step.
376        let checks = prims
377            .iter()
378            .filter(|p| matches!(p, Primitive::Path { fill: Some(_), .. }))
379            .count();
380        assert_eq!(checks, 2, "two ticks for the two done steps");
381        // All the labels are drawn.
382        assert!(has_text("A") && has_text("B") && has_text("C") && has_text("D"));
383    }
384
385    #[test]
386    fn completed_mask_overrides_position() {
387        // Without a mask: "done" = position (i < current).
388        let default = Steps::<()>::new(["A", "B", "C"]).current(2);
389        assert!(default.is_done(0) && default.is_done(1));
390        assert!(
391            !default.is_done(2),
392            "the current step is not done by default"
393        );
394        // With a mask (validity): independent of position.
395        let masked = Steps::<()>::new(["A", "B", "C"])
396            .current(1)
397            .completed([false, false, true]);
398        assert!(
399            !masked.is_done(0),
400            "step 0 invalid → not done despite i < current"
401        );
402        assert!(
403            masked.is_done(2),
404            "step 2 valid → done even though i > current"
405        );
406        // A mask shorter than the number of steps: the missing ones are not done.
407        let short = Steps::<()>::new(["A", "B", "C"]).completed([true]);
408        assert!(short.is_done(0) && !short.is_done(1) && !short.is_done(2));
409    }
410
411    #[test]
412    fn on_tap_overlays_clickable_hotspots() {
413        #[derive(Clone, Debug, PartialEq)]
414        enum Msg {
415            Go(usize),
416        }
417        // Without on_tap: no children (purely visual).
418        let plain = Steps::<Msg>::new(["A", "B", "C"]).current(1);
419        assert!(Widget::<Msg>::children(&plain).is_empty());
420        // With on_tap: a row of children whose every marker emits its index.
421        let tappable = Steps::new(["A", "B", "C"]).current(1).on_tap(Msg::Go);
422        let row = Widget::<Msg>::children(&tappable);
423        assert_eq!(row.len(), 1, "a single row of hotspots");
424        let spots = row[0].children();
425        assert_eq!(spots.len(), 3, "one hotspot per step");
426        assert_eq!(spots[0].on_click(), Some(Msg::Go(0)));
427        assert_eq!(spots[2].on_click(), Some(Msg::Go(2)));
428        assert!(spots[0].focusable(), "a hotspot is focusable");
429    }
430}