1use 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
12pub 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 children: Vec<Box<dyn Widget<Msg>>>,
22}
23
24impl<Msg: Clone + 'static> Stepper<Msg> {
25 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 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 pub fn enabled(mut self, enabled: bool) -> Self {
54 self.enabled = enabled;
55 self.rebuild();
56 self
57 }
58
59 pub fn step(mut self, step: i32) -> Self {
61 self.step = step.max(1);
62 self.rebuild();
63 self
64 }
65
66 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 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 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 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))); assert_eq!(children[2].on_click(), Some(Msg::Set(7))); }
163
164 #[test]
165 fn values_are_clamped_to_range() {
166 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))); assert_eq!(children[0].on_click(), Some(Msg::Set(6))); }
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 #[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 #[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 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 assert_ne!(
245 colour_in(&Theme::dark(), false),
246 colour_in(&Theme::light(), false)
247 );
248 }
249}