Skip to main content

teksilo_widgets/spin_box/
value.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `SpinValue` trait — abstracts numeric primitives for `SpinBox`.
5//!
6//! Implemented for `i32`, `i64`, `u32`, `u64`, `usize`, `f32`, `f64`.
7//! The trait is **sealed** via the private [`sealed::Sealed`]
8//! supertrait: only the primitive numeric types above implement it,
9//! and downstream crates cannot add their own implementations.
10//! Callers that need a custom value type (fixed-point decimals,
11//! durations, currencies, …) should wrap `SpinBox<i64>` /
12//! `SpinBox<f64>` and use
13//! [`text_from_value`](super::SpinBox::text_from_value) /
14//! [`value_from_text`](super::SpinBox::value_from_text) for the
15//! presentation layer.
16
17use std::fmt::Debug;
18
19mod sealed {
20    /// Private super-trait used to seal `SpinValue` against
21    /// downstream implementations. Only the primitive types
22    /// enumerated in the parent module implement it.
23    pub trait Sealed {}
24    impl Sealed for i32 {}
25    impl Sealed for i64 {}
26    impl Sealed for u8 {}
27    impl Sealed for u32 {}
28    impl Sealed for u64 {}
29    impl Sealed for usize {}
30    impl Sealed for f32 {}
31    impl Sealed for f64 {}
32}
33
34/// Numeric primitive that a [`SpinBox`](super::SpinBox) can hold.
35///
36/// Sealed: only the primitive integer and floating-point types
37/// implement this. See the module docs for the rationale.
38///
39/// Implementations must provide lossless parsing and round-trip
40/// formatting (`parse(format(v, d)) == Some(v)` for any finite value
41/// `v` and decimals `d`). Arithmetic is saturating so clamping into
42/// `[min, max]` after a step cannot overflow.
43pub trait SpinValue: sealed::Sealed + Copy + PartialOrd + Debug + 'static {
44    /// Lossless widening to `f64`. Used for AccessKit's numeric
45    /// value / min / max / step properties and for
46    /// [`StepType::Adaptive`](super::StepType::Adaptive) decimal
47    /// analysis.
48    fn to_f64(self) -> f64;
49
50    /// Narrowing from `f64` with saturation at the type's full
51    /// range. For integers the conversion truncates toward zero,
52    /// matching Rust's `as` conversion semantics.
53    fn from_f64_saturating(v: f64) -> Self;
54
55    /// Parse a user-entered string. Leading/trailing whitespace is
56    /// ignored. Returns `None` for syntactically invalid input
57    /// (but NOT for out-of-range values — the SpinBox clamps
58    /// separately so users can type past the bound and see the
59    /// reformatted clamped result after blur).
60    fn parse(s: &str) -> Option<Self>;
61
62    /// Format for display.
63    ///
64    /// For integer types, `decimals` is ignored. For floats, the
65    /// value is rendered with exactly `decimals` digits after the
66    /// decimal point — no scientific notation, no thousands
67    /// separator. Formatter closures on `SpinBox`
68    /// ([`text_from_value`](super::SpinBox::text_from_value))
69    /// override this.
70    fn format(self, decimals: u8) -> String;
71
72    /// Saturating addition. Out-of-type-range results clamp at
73    /// `MAX` (or `MIN` for negative overflow on signed types).
74    fn saturating_add(self, rhs: Self) -> Self;
75
76    /// Saturating subtraction. See [`saturating_add`](Self::saturating_add).
77    fn saturating_sub(self, rhs: Self) -> Self;
78
79    /// Saturating multiplication by a positive integer. Used for
80    /// `page_step = multiplier × single_step` when the caller
81    /// omits a page step.
82    fn saturating_mul_u32(self, rhs: u32) -> Self;
83
84    /// Whether this type has integer semantics (no fractional
85    /// component, no decimal separator in the default
86    /// [`format`](Self::format) path). Controls the default
87    /// character filter and whether `decimals` has any effect.
88    fn is_integer() -> bool;
89
90    /// Default per-character input filter for the editable field.
91    /// Admits digits and, for signed types, `-`; float types also
92    /// admit `.`, `+`, `e`, `E`. Callers can override the whole
93    /// filter on the `SpinBox` builder.
94    fn is_valid_input_char(c: char) -> bool;
95
96    /// Clamp into an inclusive range. Falls through to
97    /// `PartialOrd`.
98    fn clamp_value(self, min: Self, max: Self) -> Self {
99        if self < min {
100            min
101        } else if self > max {
102            max
103        } else {
104            self
105        }
106    }
107}
108
109// ── Integer implementations ─────────────────────────────────────────
110
111macro_rules! impl_spin_value_int {
112    ($t:ty, signed = $signed:expr) => {
113        impl SpinValue for $t {
114            fn to_f64(self) -> f64 {
115                self as f64
116            }
117            fn from_f64_saturating(v: f64) -> Self {
118                if v.is_nan() {
119                    return 0;
120                }
121                if v <= <$t>::MIN as f64 {
122                    return <$t>::MIN;
123                }
124                if v >= <$t>::MAX as f64 {
125                    return <$t>::MAX;
126                }
127                v as Self
128            }
129            fn parse(s: &str) -> Option<Self> {
130                s.trim().parse::<Self>().ok()
131            }
132            fn format(self, _decimals: u8) -> String {
133                self.to_string()
134            }
135            fn saturating_add(self, rhs: Self) -> Self {
136                <$t>::saturating_add(self, rhs)
137            }
138            fn saturating_sub(self, rhs: Self) -> Self {
139                <$t>::saturating_sub(self, rhs)
140            }
141            fn saturating_mul_u32(self, rhs: u32) -> Self {
142                // Widen to i128 so the multiply is always exact,
143                // then saturate into the target type's range.
144                let wide = (self as i128) * (rhs as i128);
145                if wide <= <$t>::MIN as i128 {
146                    <$t>::MIN
147                } else if wide >= <$t>::MAX as i128 {
148                    <$t>::MAX
149                } else {
150                    wide as Self
151                }
152            }
153            fn is_integer() -> bool {
154                true
155            }
156            fn is_valid_input_char(c: char) -> bool {
157                if $signed {
158                    c.is_ascii_digit() || c == '-'
159                } else {
160                    c.is_ascii_digit()
161                }
162            }
163        }
164    };
165}
166
167impl_spin_value_int!(i32, signed = true);
168impl_spin_value_int!(i64, signed = true);
169impl_spin_value_int!(u8, signed = false);
170impl_spin_value_int!(u32, signed = false);
171impl_spin_value_int!(u64, signed = false);
172impl_spin_value_int!(usize, signed = false);
173
174// ── Float implementations ───────────────────────────────────────────
175
176macro_rules! impl_spin_value_float {
177    ($t:ty) => {
178        impl SpinValue for $t {
179            fn to_f64(self) -> f64 {
180                self as f64
181            }
182            fn from_f64_saturating(v: f64) -> Self {
183                if v.is_nan() {
184                    return 0.0;
185                }
186                // `as` casts between floats already saturate to
187                // ±INFINITY on overflow and produce the nearest
188                // representable value otherwise, so no manual
189                // clamping is needed.
190                v as Self
191            }
192            fn parse(s: &str) -> Option<Self> {
193                s.trim()
194                    .parse::<Self>()
195                    .ok()
196                    .filter(|f: &Self| f.is_finite())
197            }
198            fn format(self, decimals: u8) -> String {
199                // `{:.N$}` formats with exactly N decimal places
200                // and no separator — matches Qt's default
201                // `QDoubleSpinBox` output for non-grouping locales.
202                format!("{:.*}", decimals as usize, self)
203            }
204            fn saturating_add(self, rhs: Self) -> Self {
205                let r = self + rhs;
206                if r.is_finite() {
207                    r
208                } else if r.is_sign_positive() {
209                    <$t>::MAX
210                } else {
211                    <$t>::MIN
212                }
213            }
214            fn saturating_sub(self, rhs: Self) -> Self {
215                let r = self - rhs;
216                if r.is_finite() {
217                    r
218                } else if r.is_sign_positive() {
219                    <$t>::MAX
220                } else {
221                    <$t>::MIN
222                }
223            }
224            fn saturating_mul_u32(self, rhs: u32) -> Self {
225                let r = self * (rhs as Self);
226                if r.is_finite() {
227                    r
228                } else if r.is_sign_positive() {
229                    <$t>::MAX
230                } else {
231                    <$t>::MIN
232                }
233            }
234            fn is_integer() -> bool {
235                false
236            }
237            fn is_valid_input_char(c: char) -> bool {
238                c.is_ascii_digit() || c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E'
239            }
240        }
241    };
242}
243
244impl_spin_value_float!(f32);
245impl_spin_value_float!(f64);
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn i32_round_trip() {
253        assert_eq!(<i32 as SpinValue>::parse("42"), Some(42));
254        assert_eq!(<i32 as SpinValue>::parse("-1"), Some(-1));
255        assert_eq!(<i32 as SpinValue>::parse(" 7 "), Some(7));
256        assert_eq!(<i32 as SpinValue>::parse("abc"), None);
257        assert_eq!((42_i32).format(0), "42");
258    }
259
260    #[test]
261    fn u32_rejects_negative() {
262        assert_eq!(<u32 as SpinValue>::parse("-5"), None);
263        assert!(!<u32 as SpinValue>::is_valid_input_char('-'));
264    }
265
266    #[test]
267    fn f64_formats_with_decimals() {
268        assert_eq!((std::f64::consts::PI).format(2), "3.14");
269        assert_eq!((std::f64::consts::PI).format(4), "3.1416");
270        assert_eq!((0.1_f64 + 0.2).format(1), "0.3");
271    }
272
273    #[test]
274    fn f64_parse_rejects_nan_and_infinity() {
275        assert_eq!(<f64 as SpinValue>::parse("nan"), None);
276        assert_eq!(<f64 as SpinValue>::parse("inf"), None);
277        assert_eq!(<f64 as SpinValue>::parse("3.5"), Some(3.5));
278    }
279
280    #[test]
281    fn integer_saturating_add_clamps() {
282        let v = i32::MAX - 1;
283        assert_eq!(SpinValue::saturating_add(v, 10_i32), i32::MAX);
284        assert_eq!(SpinValue::saturating_sub(i32::MIN, 1_i32), i32::MIN);
285    }
286
287    #[test]
288    fn page_step_multiply_saturates() {
289        assert_eq!(
290            SpinValue::saturating_mul_u32(1_000_000_i32, 1_000_000),
291            i32::MAX
292        );
293    }
294
295    #[test]
296    fn is_integer_flag() {
297        assert!(<i32 as SpinValue>::is_integer());
298        assert!(<u64 as SpinValue>::is_integer());
299        assert!(!<f32 as SpinValue>::is_integer());
300        assert!(!<f64 as SpinValue>::is_integer());
301    }
302
303    #[test]
304    fn clamp_value() {
305        assert_eq!((42_i32).clamp_value(0, 100), 42);
306        assert_eq!((150_i32).clamp_value(0, 100), 100);
307        assert_eq!((-5_i32).clamp_value(0, 100), 0);
308    }
309
310    #[test]
311    fn u8_round_trip() {
312        assert_eq!(<u8 as SpinValue>::parse("0"), Some(0));
313        assert_eq!(<u8 as SpinValue>::parse("128"), Some(128));
314        assert_eq!(<u8 as SpinValue>::parse("255"), Some(255));
315        assert_eq!(<u8 as SpinValue>::parse("256"), None); // overflow
316        assert_eq!(<u8 as SpinValue>::parse("-1"), None);
317        assert!(!<u8 as SpinValue>::is_valid_input_char('-'));
318        assert_eq!((255_u8).format(0), "255");
319        assert_eq!(SpinValue::saturating_add(250_u8, 10_u8), u8::MAX);
320        assert_eq!(SpinValue::saturating_sub(5_u8, 10_u8), u8::MIN);
321        assert!(<u8 as SpinValue>::is_integer());
322    }
323}