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("", ¤t.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}