use frus_core::{Rect, Scene};
use frus_layout::{Align, FlexDirection, Style};
use crate::disabled::disabled_content;
use crate::interaction::Status;
use crate::text::Text;
use crate::theme::Theme;
use crate::widget::Widget;
pub struct Stepper<Msg = crate::callback::Callback> {
value: i32,
min: i32,
max: i32,
step: i32,
enabled: bool,
on_change: Box<dyn Fn(i32) -> Msg>,
children: Vec<Box<dyn Widget<Msg>>>,
}
impl<Msg: Clone + 'static> Stepper<Msg> {
pub fn new(value: i32, on_change: impl Fn(i32) -> Msg + 'static) -> Self {
let mut stepper = Self {
value,
min: i32::MIN,
max: i32::MAX,
step: 1,
enabled: true,
on_change: Box::new(on_change),
children: Vec::new(),
};
stepper.rebuild();
stepper
}
pub fn range(mut self, min: i32, max: i32) -> Self {
self.min = min;
self.max = max;
self.rebuild();
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self.rebuild();
self
}
pub fn step(mut self, step: i32) -> Self {
self.step = step.max(1);
self.rebuild();
self
}
fn rebuild(&mut self) {
let dec = (self.value - self.step).clamp(self.min, self.max);
let inc = (self.value + self.step).clamp(self.min, self.max);
let can_dec = self.enabled && self.value > self.min;
let can_inc = self.enabled && self.value < self.max;
self.children = vec![
Box::new(
crate::IconButton::glyph("−")
.label(crate::localizations::of().decrease_label())
.variant(crate::IconButtonVariant::Outlined)
.icon_size(20.0)
.enabled(can_dec)
.on_press((self.on_change)(dec)),
),
Box::new({
let (text, enabled) = (self.value.to_string(), self.enabled);
crate::ThemeBuilder::new(move |theme| {
let value = Text::new(text).size(18.0);
if enabled {
value
} else {
value.color(disabled_content(theme))
}
})
}),
Box::new(
crate::IconButton::glyph("+")
.label(crate::localizations::of().increase_label())
.variant(crate::IconButtonVariant::Outlined)
.icon_size(20.0)
.enabled(can_inc)
.on_press((self.on_change)(inc)),
),
];
}
}
impl<Msg: Clone> Widget<Msg> for Stepper<Msg> {
fn style(&self) -> Style {
Style {
flex_direction: FlexDirection::Row,
align: Align::Center,
gap: 12.0,
..Default::default()
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&self.children
}
fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
fn on_click(&self) -> Option<Msg> {
None
}
fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Slider)
.value(self.value.to_string())
.range(self.min as f32, self.value as f32, self.max as f32);
Some(if self.enabled {
semantics
} else {
semantics.disabled(true)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Set(i32),
}
#[test]
fn buttons_emit_stepped_values() {
let stepper = Stepper::new(5, Msg::Set).range(0, 10).step(2);
let children = Widget::<Msg>::children(&stepper);
assert_eq!(children[0].on_click(), Some(Msg::Set(3))); assert_eq!(children[2].on_click(), Some(Msg::Set(7))); }
#[test]
fn values_are_clamped_to_range() {
let near_max = Stepper::new(9, Msg::Set).range(0, 10).step(3);
let children = Widget::<Msg>::children(&near_max);
assert_eq!(children[2].on_click(), Some(Msg::Set(10))); assert_eq!(children[0].on_click(), Some(Msg::Set(6))); }
#[test]
fn a_disabled_picker_is_inert_but_still_readable() {
let dead = Stepper::new(5, Msg::Set).range(0, 10).enabled(false);
let children = Widget::<Msg>::children(&dead);
assert_eq!(children[0].on_click(), None, "minus still answers");
assert_eq!(children[2].on_click(), None, "plus still answers");
assert!(!children[0].focusable(), "minus is still in the tab order");
assert!(!children[2].focusable(), "plus is still in the tab order");
let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
assert!(semantics.disabled);
assert_eq!(semantics.value.as_deref(), Some("5"), "the value survives");
}
#[test]
fn a_button_at_its_bound_is_disabled_rather_than_merely_clamped() {
let at_max = Stepper::new(10, Msg::Set).range(0, 10);
let kids = Widget::<Msg>::children(&at_max);
assert_eq!(kids[2].on_click(), None, "plus at the top does nothing");
assert!(!kids[2].focusable(), "and Tab does not stop there");
assert_eq!(kids[0].on_click(), Some(Msg::Set(9)), "minus still works");
let at_min = Stepper::new(0, Msg::Set).range(0, 10);
let kids = Widget::<Msg>::children(&at_min);
assert_eq!(kids[0].on_click(), None, "minus at the bottom does nothing");
assert_eq!(kids[2].on_click(), Some(Msg::Set(1)), "plus still works");
}
#[test]
fn the_disabled_value_takes_the_ambient_theme_not_a_default_one() {
let colour_in = |theme: &Theme, enabled: bool| {
let stepper = Stepper::new(5, Msg::Set).range(0, 10).enabled(enabled);
let value = &Widget::<Msg>::children(&stepper)[1];
value.build_themed(theme);
let mut scene = Scene::new();
value.children()[0].paint(
Rect::new(0.0, 0.0, 40.0, 24.0),
Status {
opacity: 1.0,
..Default::default()
},
theme,
&mut scene,
);
scene
.primitives()
.iter()
.find_map(|p| match p {
frus_core::Primitive::Text { color, .. } => Some(*color),
_ => None,
})
.expect("the value is text")
};
for theme in [Theme::dark(), Theme::light()] {
assert_eq!(colour_in(&theme, false), disabled_content(&theme));
assert_ne!(
colour_in(&theme, true),
disabled_content(&theme),
"a live value is not greyed"
);
}
assert_ne!(
colour_in(&Theme::dark(), false),
colour_in(&Theme::light(), false)
);
}
}