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}