Skip to main content

frus_widgets/
stepper.rs

1//! [`Stepper`]: a **controlled** numeric field with **−/value/+** buttons.
2
3use frus_core::{Rect, Scene};
4use frus_layout::{Align, FlexDirection, Style};
5
6use crate::disabled::disabled_content;
7use crate::interaction::Status;
8use crate::text::Text;
9use crate::theme::Theme;
10use crate::widget::Widget;
11
12/// An incremental numeric picker.
13pub struct Stepper<Msg = crate::callback::Callback> {
14    value: i32,
15    min: i32,
16    max: i32,
17    step: i32,
18    enabled: bool,
19    on_change: Box<dyn Fn(i32) -> Msg>,
20    /// `[−, value, +]`.
21    children: Vec<Box<dyn Widget<Msg>>>,
22}
23
24impl<Msg: Clone + 'static> Stepper<Msg> {
25    /// Creates a picker: the current value plus a message for each new value.
26    pub fn new(value: i32, on_change: impl Fn(i32) -> Msg + 'static) -> Self {
27        let mut stepper = Self {
28            value,
29            min: i32::MIN,
30            max: i32::MAX,
31            step: 1,
32            enabled: true,
33            on_change: Box::new(on_change),
34            children: Vec::new(),
35        };
36        stepper.rebuild();
37        stepper
38    }
39
40    /// Clamps the value to `[min, max]`.
41    pub fn range(mut self, min: i32, max: i32) -> Self {
42        self.min = min;
43        self.max = max;
44        self.rebuild();
45        self
46    }
47
48    /// Whether the value can be changed. Disabled, **both** buttons are inert and the
49    /// value greys with them - and it is still readable, because read-only is not
50    /// invisible.
51    ///
52    /// See [`crate::disabled`] for the whole contract.
53    pub fn enabled(mut self, enabled: bool) -> Self {
54        self.enabled = enabled;
55        self.rebuild();
56        self
57    }
58
59    /// Sets the increment step, at least 1.
60    pub fn step(mut self, step: i32) -> Self {
61        self.step = step.max(1);
62        self.rebuild();
63        self
64    }
65
66    /// (Re)builds the three children from the value and the bounds.
67    fn rebuild(&mut self) {
68        let dec = (self.value - self.step).clamp(self.min, self.max);
69        let inc = (self.value + self.step).clamp(self.min, self.max);
70        // **A button at its bound is disabled**, not merely clamped. Until milestone 324
71        // both stayed live at the ends of the range and emitted the value the picker was
72        // already showing: a control that looks pressable, is pressable, and does nothing.
73        // Now the end of the range is visible before it is reached.
74        let can_dec = self.enabled && self.value > self.min;
75        let can_inc = self.enabled && self.value < self.max;
76
77        self.children = vec![
78            Box::new(
79                crate::IconButton::glyph("−")
80                    .label(crate::localizations::of().decrease_label())
81                    .variant(crate::IconButtonVariant::Outlined)
82                    .icon_size(20.0)
83                    .enabled(can_dec)
84                    .on_press((self.on_change)(dec)),
85            ),
86            // The value's colour needs the **ambient** theme, and a stepper is assembled
87            // before it has one. That is exactly what milestone 319's `ThemeBuilder` was
88            // built for, and this is its second consumer: reaching for `Theme::default()`
89            // here would paint a dark-theme grey into a light-theme application.
90            Box::new({
91                let (text, enabled) = (self.value.to_string(), self.enabled);
92                crate::ThemeBuilder::new(move |theme| {
93                    let value = Text::new(text).size(18.0);
94                    if enabled {
95                        value
96                    } else {
97                        value.color(disabled_content(theme))
98                    }
99                })
100            }),
101            Box::new(
102                crate::IconButton::glyph("+")
103                    .label(crate::localizations::of().increase_label())
104                    .variant(crate::IconButtonVariant::Outlined)
105                    .icon_size(20.0)
106                    .enabled(can_inc)
107                    .on_press((self.on_change)(inc)),
108            ),
109        ];
110    }
111}
112
113impl<Msg: Clone> Widget<Msg> for Stepper<Msg> {
114    fn style(&self) -> Style {
115        Style {
116            flex_direction: FlexDirection::Row,
117            align: Align::Center,
118            gap: 12.0,
119            ..Default::default()
120        }
121    }
122
123    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
124        &self.children
125    }
126
127    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
128
129    fn on_click(&self) -> Option<Msg> {
130        None
131    }
132
133    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
134        // The picker said nothing to a reader before this; its value is the whole of what
135        // it carries.
136        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Slider)
137            .value(self.value.to_string())
138            .range(self.min as f32, self.value as f32, self.max as f32);
139        Some(if self.enabled {
140            semantics
141        } else {
142            semantics.disabled(true)
143        })
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[derive(Clone, Debug, PartialEq)]
152    enum Msg {
153        Set(i32),
154    }
155
156    #[test]
157    fn buttons_emit_stepped_values() {
158        let stepper = Stepper::new(5, Msg::Set).range(0, 10).step(2);
159        let children = Widget::<Msg>::children(&stepper);
160        assert_eq!(children[0].on_click(), Some(Msg::Set(3))); // −
161        assert_eq!(children[2].on_click(), Some(Msg::Set(7))); // +
162    }
163
164    #[test]
165    fn values_are_clamped_to_range() {
166        // A step that would overshoot lands on the bound. This used to be asserted *at*
167        // the bound, where the button emitted the value already showing; milestone 324
168        // disables it there instead, so the clamp is checked one step short of the top —
169        // which is the only place it can still be observed.
170        let near_max = Stepper::new(9, Msg::Set).range(0, 10).step(3);
171        let children = Widget::<Msg>::children(&near_max);
172        assert_eq!(children[2].on_click(), Some(Msg::Set(10))); // + clamped to max
173        assert_eq!(children[0].on_click(), Some(Msg::Set(6))); // −
174    }
175
176    #[test]
177    fn a_disabled_picker_is_inert_but_still_readable() {
178        let dead = Stepper::new(5, Msg::Set).range(0, 10).enabled(false);
179        let children = Widget::<Msg>::children(&dead);
180        assert_eq!(children[0].on_click(), None, "minus still answers");
181        assert_eq!(children[2].on_click(), None, "plus still answers");
182        assert!(!children[0].focusable(), "minus is still in the tab order");
183        assert!(!children[2].focusable(), "plus is still in the tab order");
184        let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
185        assert!(semantics.disabled);
186        assert_eq!(semantics.value.as_deref(), Some("5"), "the value survives");
187    }
188
189    /// A button at the end of its range used to stay live and emit the value already
190    /// shown - pressable, and doing nothing. The bound is visible now.
191    #[test]
192    fn a_button_at_its_bound_is_disabled_rather_than_merely_clamped() {
193        let at_max = Stepper::new(10, Msg::Set).range(0, 10);
194        let kids = Widget::<Msg>::children(&at_max);
195        assert_eq!(kids[2].on_click(), None, "plus at the top does nothing");
196        assert!(!kids[2].focusable(), "and Tab does not stop there");
197        assert_eq!(kids[0].on_click(), Some(Msg::Set(9)), "minus still works");
198
199        let at_min = Stepper::new(0, Msg::Set).range(0, 10);
200        let kids = Widget::<Msg>::children(&at_min);
201        assert_eq!(kids[0].on_click(), None, "minus at the bottom does nothing");
202        assert_eq!(kids[2].on_click(), Some(Msg::Set(1)), "plus still works");
203    }
204
205    /// The value's colour comes from the **ambient** theme, through milestone 319's
206    /// `ThemeBuilder`. Building it against `Theme::default()` at assembly time would paint
207    /// a dark-theme grey into a light-theme application, and nothing in a dark-themed
208    /// golden would ever show it.
209    #[test]
210    fn the_disabled_value_takes_the_ambient_theme_not_a_default_one() {
211        let colour_in = |theme: &Theme, enabled: bool| {
212            let stepper = Stepper::new(5, Msg::Set).range(0, 10).enabled(enabled);
213            let value = &Widget::<Msg>::children(&stepper)[1];
214            // What the layout pass does on the way down, before anything reads children.
215            value.build_themed(theme);
216            let mut scene = Scene::new();
217            value.children()[0].paint(
218                Rect::new(0.0, 0.0, 40.0, 24.0),
219                Status {
220                    opacity: 1.0,
221                    ..Default::default()
222                },
223                theme,
224                &mut scene,
225            );
226            scene
227                .primitives()
228                .iter()
229                .find_map(|p| match p {
230                    frus_core::Primitive::Text { color, .. } => Some(*color),
231                    _ => None,
232                })
233                .expect("the value is text")
234        };
235        for theme in [Theme::dark(), Theme::light()] {
236            assert_eq!(colour_in(&theme, false), disabled_content(&theme));
237            assert_ne!(
238                colour_in(&theme, true),
239                disabled_content(&theme),
240                "a live value is not greyed"
241            );
242        }
243        // And the two themes disagree, which is the whole point of deferring it.
244        assert_ne!(
245            colour_in(&Theme::dark(), false),
246            colour_in(&Theme::light(), false)
247        );
248    }
249}