Skip to main content

maolan_widgets/
numeric_input.rs

1use iced::{
2    Alignment, Background, Border, Color, Element, Length, Theme,
3    widget::{button, column, container, row, text_input},
4};
5use iced_fonts::lucide::{chevron_down, chevron_up};
6use std::{
7    fmt::Display,
8    ops::{Add, RangeInclusive, Sub},
9    str::FromStr,
10};
11
12fn spinner_button_style(theme: &Theme, status: button::Status) -> button::Style {
13    let palette = theme.extended_palette();
14    let active_bg = palette.primary.strong.color;
15    let hovered_bg = palette.primary.base.color;
16    let disabled_bg = Color {
17        a: active_bg.a * 0.4,
18        ..active_bg
19    };
20    let mut style = button::Style {
21        background: Some(Background::Color(match status {
22            button::Status::Hovered | button::Status::Pressed => hovered_bg,
23            button::Status::Disabled => disabled_bg,
24            _ => active_bg,
25        })),
26        text_color: match status {
27            button::Status::Disabled => Color {
28                a: palette.primary.strong.text.a * 0.45,
29                ..palette.primary.strong.text
30            },
31            _ => palette.primary.strong.text,
32        },
33        ..button::Style::default()
34    };
35    style.border = Border {
36        color: Color::from_rgba(0.0, 0.0, 0.0, 0.0),
37        width: 1.0,
38        radius: 3.0.into(),
39    };
40    style
41}
42
43fn shell_style(_theme: &Theme) -> container::Style {
44    container::Style {
45        text_color: Some(Color::from_rgb(0.92, 0.92, 0.92)),
46        background: Some(Background::Color(Color::from_rgba(0.10, 0.10, 0.10, 1.0))),
47        border: Border {
48            color: Color::from_rgba(0.28, 0.28, 0.28, 1.0),
49            width: 1.0,
50            radius: 2.0.into(),
51        },
52        ..container::Style::default()
53    }
54}
55
56fn input_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
57    let mut style = text_input::default(theme, status);
58    style.background = Background::Color(Color::TRANSPARENT);
59    style.border = Border {
60        color: Color::TRANSPARENT,
61        width: 0.0,
62        radius: 0.0.into(),
63    };
64    style
65}
66
67pub fn number_input<'a, T, Message>(
68    value: &'a T,
69    bounds: RangeInclusive<T>,
70    on_change: impl Fn(T) -> Message + 'a + Copy,
71) -> Element<'a, Message>
72where
73    T: Copy + Display + FromStr + PartialOrd + Add<Output = T> + Sub<Output = T> + From<u8> + 'a,
74    Message: Clone + 'a,
75{
76    let min = *bounds.start();
77    let max = *bounds.end();
78    let current = *value;
79    let step = T::from(1_u8);
80    let dec_value = if current > min + step {
81        current - step
82    } else {
83        min
84    };
85    let inc_value = if current < max - step {
86        current + step
87    } else {
88        max
89    };
90
91    let input = text_input("", &current.to_string())
92        .on_input(move |raw| {
93            raw.parse::<T>()
94                .map(|parsed| {
95                    let clamped = if parsed < min {
96                        min
97                    } else if parsed > max {
98                        max
99                    } else {
100                        parsed
101                    };
102                    on_change(clamped)
103                })
104                .unwrap_or_else(|_| on_change(current))
105        })
106        .style(input_style)
107        .padding([5, 8])
108        .width(Length::Fixed(72.0))
109        .size(14);
110
111    let decrement = button(
112        container(chevron_down().size(14))
113            .center_x(Length::Fill)
114            .center_y(Length::Fill),
115    )
116    .style(spinner_button_style)
117    .padding(0)
118    .width(Length::Fixed(22.0))
119    .height(Length::Fixed(15.0));
120    let decrement = if current > min {
121        decrement.on_press(on_change(dec_value))
122    } else {
123        decrement
124    };
125
126    let increment = button(
127        container(chevron_up().size(14))
128            .center_x(Length::Fill)
129            .center_y(Length::Fill),
130    )
131    .style(spinner_button_style)
132    .padding(0)
133    .width(Length::Fixed(22.0))
134    .height(Length::Fixed(15.0));
135    let increment = if current < max {
136        increment.on_press(on_change(inc_value))
137    } else {
138        increment
139    };
140
141    container(
142        row![
143            container(input)
144                .width(Length::Fixed(72.0))
145                .center_y(Length::Fixed(30.0)),
146            column![increment, decrement]
147                .spacing(0)
148                .width(Length::Fixed(22.0))
149                .align_x(Alignment::Center),
150        ]
151        .spacing(0)
152        .align_y(Alignment::Center),
153    )
154    .style(shell_style)
155    .into()
156}
157
158fn format_decimal_value(value: f32) -> String {
159    let mut formatted = format!("{value:.3}");
160    while formatted.contains('.') && formatted.ends_with('0') {
161        formatted.pop();
162    }
163    if formatted.ends_with('.') {
164        formatted.pop();
165    }
166    formatted
167}
168
169pub fn number_input_f32<'a, Message>(
170    value: &'a str,
171    bounds: RangeInclusive<f32>,
172    step: f32,
173    on_change: impl Fn(String) -> Message + 'a + Copy,
174) -> Element<'a, Message>
175where
176    Message: Clone + 'a,
177{
178    let min = *bounds.start();
179    let max = *bounds.end();
180    let parsed_current = value.trim().parse::<f32>().ok();
181    let current = parsed_current.unwrap_or(min).clamp(min, max);
182    let dec_value = (current - step).clamp(min, max);
183    let inc_value = (current + step).clamp(min, max);
184
185    let input = text_input("", value)
186        .on_input(on_change)
187        .style(input_style)
188        .padding([5, 8])
189        .width(Length::Fixed(72.0))
190        .size(14);
191
192    let decrement = button(
193        container(chevron_down().size(14))
194            .center_x(Length::Fill)
195            .center_y(Length::Fill),
196    )
197    .style(spinner_button_style)
198    .padding(0)
199    .width(Length::Fixed(22.0))
200    .height(Length::Fixed(15.0));
201    let decrement = if current > min {
202        decrement.on_press(on_change(format_decimal_value(dec_value)))
203    } else {
204        decrement
205    };
206
207    let increment = button(
208        container(chevron_up().size(14))
209            .center_x(Length::Fill)
210            .center_y(Length::Fill),
211    )
212    .style(spinner_button_style)
213    .padding(0)
214    .width(Length::Fixed(22.0))
215    .height(Length::Fixed(15.0));
216    let increment = if current < max {
217        increment.on_press(on_change(format_decimal_value(inc_value)))
218    } else {
219        increment
220    };
221
222    container(
223        row![
224            container(input)
225                .width(Length::Fixed(72.0))
226                .center_y(Length::Fixed(30.0)),
227            column![increment, decrement]
228                .spacing(0)
229                .width(Length::Fixed(22.0))
230                .align_x(Alignment::Center),
231        ]
232        .spacing(0)
233        .align_y(Alignment::Center),
234    )
235    .style(shell_style)
236    .into()
237}
238
239#[cfg(test)]
240mod tests {
241    use super::format_decimal_value;
242
243    #[test]
244    fn format_decimal_value_trims_trailing_zeroes() {
245        assert_eq!(format_decimal_value(6.1), "6.1");
246        assert_eq!(format_decimal_value(6.0), "6");
247        assert_eq!(format_decimal_value(6.125), "6.125");
248    }
249
250    #[test]
251    fn format_decimal_value_handles_negative() {
252        assert_eq!(format_decimal_value(-6.5), "-6.5");
253        assert_eq!(format_decimal_value(-6.0), "-6");
254    }
255
256    #[test]
257    fn format_decimal_value_handles_zero() {
258        assert_eq!(format_decimal_value(0.0), "0");
259    }
260
261    #[test]
262    fn format_decimal_value_handles_large_numbers() {
263        assert_eq!(format_decimal_value(1234.567), "1234.567");
264        assert_eq!(format_decimal_value(1000.0), "1000");
265    }
266
267    #[test]
268    fn format_decimal_value_handles_small_decimals() {
269        assert_eq!(format_decimal_value(0.001), "0.001");
270        assert_eq!(format_decimal_value(0.000), "0");
271    }
272}