ailloli_ui_core/progress.rs
1//! Pure progress value mapping helpers.
2
3/// Numeric domain used by determinate progress indicators.
4///
5/// # Examples
6///
7/// ```
8/// use ailloli_ui_core::ProgressSpec;
9///
10/// let progress = ProgressSpec::new(0.0, 10.0);
11/// assert_eq!(progress.fraction_for_value(5.0), 0.5);
12/// ```
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct ProgressSpec {
15 /// Inclusive value represented by an empty indicator.
16 pub min: f32,
17 /// Inclusive value represented by a full indicator.
18 pub max: f32,
19}
20
21impl Default for ProgressSpec {
22 fn default() -> Self {
23 Self { min: 0.0, max: 1.0 }
24 }
25}
26
27impl ProgressSpec {
28 /// Creates an ascending progress domain with finite input bounds.
29 ///
30 /// Reversed bounds are swapped, equal bounds are expanded upward by adding
31 /// `1.0` to the maximum, and any non-finite input bound resets the whole
32 /// range to `0.0..=1.0`. At magnitudes where that increment is not
33 /// representable, equal bounds can remain equal.
34 ///
35 /// # Examples
36 ///
37 /// ```
38 /// use ailloli_ui_core::ProgressSpec;
39 ///
40 /// assert_eq!(ProgressSpec::new(10.0, 0.0), ProgressSpec { min: 0.0, max: 10.0 });
41 /// ```
42 pub fn new(min: f32, max: f32) -> Self {
43 Self { min, max }.sanitized()
44 }
45
46 /// Returns this specification normalized according to [`Self::new`].
47 ///
48 /// # Examples
49 ///
50 /// ```
51 /// use ailloli_ui_core::ProgressSpec;
52 ///
53 /// let spec = ProgressSpec { min: 5.0, max: 5.0 }.sanitized();
54 /// assert_eq!(spec.max, 6.0);
55 /// ```
56 pub fn sanitized(self) -> Self {
57 let fallback = Self::default();
58 let (mut min, mut max) = if self.min.is_finite() && self.max.is_finite() {
59 (self.min, self.max)
60 } else {
61 (fallback.min, fallback.max)
62 };
63 if min > max {
64 std::mem::swap(&mut min, &mut max);
65 }
66 if min == max {
67 max = min + 1.0;
68 }
69 Self { min, max }
70 }
71
72 /// Returns `max - min` for the sanitized domain.
73 ///
74 /// The result is normally positive and finite. It can be zero when two
75 /// large equal bounds cannot be widened by `1.0`, or infinity when the
76 /// difference between finite extreme bounds overflows.
77 ///
78 /// # Examples
79 ///
80 /// ```
81 /// use ailloli_ui_core::ProgressSpec;
82 ///
83 /// assert_eq!(ProgressSpec::new(10.0, 30.0).span(), 20.0);
84 /// ```
85 pub fn span(self) -> f32 {
86 let spec = self.sanitized();
87 spec.max - spec.min
88 }
89
90 /// Clamps a value into the inclusive sanitized domain.
91 ///
92 /// A non-finite value selects the minimum.
93 ///
94 /// # Examples
95 ///
96 /// ```
97 /// use ailloli_ui_core::ProgressSpec;
98 ///
99 /// assert_eq!(ProgressSpec::new(10.0, 20.0).clamp_value(25.0), 20.0);
100 /// ```
101 pub fn clamp_value(self, value: f32) -> f32 {
102 let spec = self.sanitized();
103 let value = if value.is_finite() { value } else { spec.min };
104 value.clamp(spec.min, spec.max)
105 }
106
107 /// Maps a value into `0.0..=1.0`, clamping values outside the domain.
108 ///
109 /// A zero or overflowing span caused by extreme finite bounds can make the
110 /// IEEE-754 division return `NaN`.
111 ///
112 /// # Examples
113 ///
114 /// ```
115 /// use ailloli_ui_core::ProgressSpec;
116 ///
117 /// assert_eq!(ProgressSpec::new(10.0, 30.0).fraction_for_value(20.0), 0.5);
118 /// ```
119 pub fn fraction_for_value(self, value: f32) -> f32 {
120 let spec = self.sanitized();
121 (spec.clamp_value(value) - spec.min) / spec.span()
122 }
123
124 /// Maps a normalized fraction back into the progress domain.
125 ///
126 /// Fractions outside `0.0..=1.0` are clamped and non-finite fractions
127 /// select the minimum. An overflowing span between extreme finite bounds
128 /// can make the returned value non-finite.
129 ///
130 /// # Examples
131 ///
132 /// ```
133 /// use ailloli_ui_core::ProgressSpec;
134 ///
135 /// assert_eq!(ProgressSpec::new(10.0, 30.0).value_for_fraction(0.75), 25.0);
136 /// ```
137 pub fn value_for_fraction(self, fraction: f32) -> f32 {
138 let spec = self.sanitized();
139 let fraction = if fraction.is_finite() {
140 fraction.clamp(0.0, 1.0)
141 } else {
142 0.0
143 };
144 spec.min + fraction * spec.span()
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 //! Covers invalid domains, non-finite values, clamping, and bidirectional mapping.
151
152 use super::*;
153
154 #[test]
155 fn spec_sanitizes_invalid_ranges() {
156 assert_eq!(
157 ProgressSpec::new(10.0, 0.0),
158 ProgressSpec {
159 min: 0.0,
160 max: 10.0
161 }
162 );
163 assert_eq!(
164 ProgressSpec::new(4.0, 4.0),
165 ProgressSpec { min: 4.0, max: 5.0 }
166 );
167 assert_eq!(
168 ProgressSpec {
169 min: f32::NAN,
170 max: 10.0,
171 }
172 .sanitized(),
173 ProgressSpec::default()
174 );
175 }
176
177 #[test]
178 fn clamp_value_bounds_and_nan() {
179 let spec = ProgressSpec::new(10.0, 20.0);
180 assert_eq!(spec.clamp_value(5.0), 10.0);
181 assert_eq!(spec.clamp_value(25.0), 20.0);
182 assert_eq!(spec.clamp_value(f32::NAN), 10.0);
183 }
184
185 #[test]
186 fn fraction_mapping_is_stable() {
187 let spec = ProgressSpec::new(10.0, 30.0);
188 assert_eq!(spec.fraction_for_value(10.0), 0.0);
189 assert_eq!(spec.fraction_for_value(20.0), 0.5);
190 assert_eq!(spec.fraction_for_value(30.0), 1.0);
191 assert_eq!(spec.fraction_for_value(60.0), 1.0);
192 }
193
194 #[test]
195 fn value_for_fraction_clamps_fraction() {
196 let spec = ProgressSpec::new(10.0, 30.0);
197 assert_eq!(spec.value_for_fraction(-1.0), 10.0);
198 assert_eq!(spec.value_for_fraction(0.75), 25.0);
199 assert_eq!(spec.value_for_fraction(2.0), 30.0);
200 assert_eq!(spec.value_for_fraction(f32::NAN), 10.0);
201 }
202}