ailloli_ui_core/slider.rs
1//! Pure slider value mapping and range helpers.
2
3/// Numeric domain and optional snapping interval for a slider.
4///
5/// # Examples
6///
7/// ```
8/// use ailloli_ui_core::SliderSpec;
9///
10/// let spec = SliderSpec::new(0.0, 10.0).with_step(2.0);
11/// assert_eq!(spec.snap_value(3.0), 4.0);
12/// ```
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct SliderSpec {
15 /// Inclusive minimum slider value.
16 pub min: f32,
17 /// Inclusive maximum slider value.
18 pub max: f32,
19 /// Positive snapping interval, or `None` for continuous values.
20 pub step: Option<f32>,
21}
22
23impl Default for SliderSpec {
24 fn default() -> Self {
25 Self {
26 min: 0.0,
27 max: 100.0,
28 step: None,
29 }
30 }
31}
32
33impl SliderSpec {
34 /// Creates a continuous, sanitized slider domain.
35 ///
36 /// Reversed bounds are swapped, equal bounds are expanded upward by adding
37 /// `1.0` to the maximum, and any non-finite input bound resets the domain
38 /// to the default `0.0..=100.0`. At magnitudes where that increment is not
39 /// representable, equal bounds can remain equal.
40 ///
41 /// # Examples
42 ///
43 /// ```
44 /// use ailloli_ui_core::SliderSpec;
45 /// assert_eq!(SliderSpec::new(10.0, 0.0).min, 0.0);
46 /// ```
47 pub fn new(min: f32, max: f32) -> Self {
48 Self {
49 min,
50 max,
51 step: None,
52 }
53 .sanitized()
54 }
55
56 /// Sets a snapping interval and sanitizes the complete specification.
57 ///
58 /// A non-positive or non-finite step is discarded and produces a
59 /// continuous slider.
60 ///
61 /// # Examples
62 ///
63 /// ```
64 /// use ailloli_ui_core::SliderSpec;
65 /// assert_eq!(SliderSpec::new(0.0, 10.0).with_step(2.0).step, Some(2.0));
66 /// ```
67 pub fn with_step(mut self, step: f32) -> Self {
68 self.step = Some(step);
69 self.sanitized()
70 }
71
72 /// Returns finite, ascending bounds and a valid optional step.
73 ///
74 /// Equal bounds are widened by adding `1.0` to `max`; `f32` rounding can
75 /// leave very large equal bounds unchanged.
76 ///
77 /// # Examples
78 ///
79 /// ```
80 /// use ailloli_ui_core::SliderSpec;
81 /// let spec = SliderSpec { min: 4.0, max: 4.0, step: Some(-1.0) }.sanitized();
82 /// assert_eq!((spec.max, spec.step), (5.0, None));
83 /// ```
84 pub fn sanitized(self) -> Self {
85 let fallback = Self::default();
86 let (mut min, mut max) = if self.min.is_finite() && self.max.is_finite() {
87 (self.min, self.max)
88 } else {
89 (fallback.min, fallback.max)
90 };
91 if min > max {
92 std::mem::swap(&mut min, &mut max);
93 }
94 if min == max {
95 max = min + 1.0;
96 }
97 let step = self.step.filter(|step| step.is_finite() && *step > 0.0);
98 Self { min, max, step }
99 }
100
101 /// Returns `max - min` for the sanitized domain.
102 ///
103 /// The result is normally positive and finite. It can be zero when two
104 /// large equal bounds cannot be widened by `1.0`, or infinity when the
105 /// difference between finite extreme bounds overflows.
106 ///
107 /// # Examples
108 ///
109 /// ```
110 /// use ailloli_ui_core::SliderSpec;
111 /// assert_eq!(SliderSpec::new(10.0, 30.0).span(), 20.0);
112 /// ```
113 pub fn span(self) -> f32 {
114 let spec = self.sanitized();
115 spec.max - spec.min
116 }
117
118 /// Clamps `value` to the inclusive domain; non-finite values select `min`.
119 ///
120 /// # Examples
121 ///
122 /// ```
123 /// use ailloli_ui_core::SliderSpec;
124 /// assert_eq!(SliderSpec::new(0.0, 10.0).clamp_value(12.0), 10.0);
125 /// ```
126 pub fn clamp_value(self, value: f32) -> f32 {
127 let spec = self.sanitized();
128 let value = if value.is_finite() { value } else { spec.min };
129 value.clamp(spec.min, spec.max)
130 }
131
132 /// Clamps a value and rounds it to the nearest step relative to `min`.
133 ///
134 /// Half-step ties follow [`f32::round`] and therefore round away from zero
135 /// in step coordinates. Continuous specifications only clamp the value.
136 ///
137 /// # Examples
138 ///
139 /// ```
140 /// use ailloli_ui_core::SliderSpec;
141 /// assert_eq!(SliderSpec::new(0.0, 10.0).with_step(2.0).snap_value(3.0), 4.0);
142 /// ```
143 pub fn snap_value(self, value: f32) -> f32 {
144 let spec = self.sanitized();
145 let value = spec.clamp_value(value);
146 let Some(step) = spec.step else {
147 return value;
148 };
149 let snapped = spec.min + ((value - spec.min) / step).round() * step;
150 spec.clamp_value(snapped)
151 }
152
153 /// Maps a value into the inclusive normalized interval `0.0..=1.0`.
154 ///
155 /// A zero or overflowing span caused by extreme finite bounds can make the
156 /// IEEE-754 division return `NaN`.
157 ///
158 /// # Examples
159 ///
160 /// ```
161 /// use ailloli_ui_core::SliderSpec;
162 /// assert_eq!(SliderSpec::new(10.0, 30.0).fraction_for_value(20.0), 0.5);
163 /// ```
164 pub fn fraction_for_value(self, value: f32) -> f32 {
165 let spec = self.sanitized();
166 (spec.clamp_value(value) - spec.min) / spec.span()
167 }
168
169 /// Maps a normalized fraction into the domain and applies step snapping.
170 ///
171 /// Fractions are clamped to `0.0..=1.0`; a non-finite fraction selects the
172 /// minimum. Intermediate arithmetic can overflow for extreme finite bounds;
173 /// the final snapping pass then applies its normal non-finite-value rules.
174 ///
175 /// # Examples
176 ///
177 /// ```
178 /// use ailloli_ui_core::SliderSpec;
179 /// assert_eq!(SliderSpec::new(10.0, 30.0).value_for_fraction(0.75), 25.0);
180 /// ```
181 pub fn value_for_fraction(self, fraction: f32) -> f32 {
182 let spec = self.sanitized();
183 let fraction = if fraction.is_finite() {
184 fraction.clamp(0.0, 1.0)
185 } else {
186 0.0
187 };
188 spec.snap_value(spec.min + fraction * spec.span())
189 }
190
191 /// Moves a value one small or large increment in `direction`.
192 ///
193 /// A small increment is the configured step or 1% of the domain. A large
194 /// increment is always 10% of the domain before optional step snapping.
195 /// Only the sign of `direction` matters; zero leaves the value at its
196 /// nearest valid step, and a non-finite direction ultimately selects `min`.
197 ///
198 /// # Examples
199 ///
200 /// ```
201 /// use ailloli_ui_core::SliderSpec;
202 /// let spec = SliderSpec::new(0.0, 100.0).with_step(5.0);
203 /// assert_eq!(spec.nudge_value(50.0, 1.0, false), 55.0);
204 /// ```
205 pub fn nudge_value(self, value: f32, direction: f32, large: bool) -> f32 {
206 let spec = self.sanitized();
207 let amount = spec.step.unwrap_or_else(|| spec.span() * 0.01);
208 let amount = if large { spec.span() * 0.10 } else { amount };
209 spec.snap_value(value + amount * direction.signum())
210 }
211
212 /// Clamps, snaps, and orders both thumbs of a range value.
213 ///
214 /// # Examples
215 ///
216 /// ```
217 /// use ailloli_ui_core::{SliderRangeValue, SliderSpec};
218 /// let spec = SliderSpec::new(0.0, 100.0).with_step(10.0);
219 /// assert_eq!(spec.clamp_range_value(SliderRangeValue::new(12.0, 83.0)), SliderRangeValue::new(10.0, 80.0));
220 /// ```
221 pub fn clamp_range_value(self, value: SliderRangeValue) -> SliderRangeValue {
222 let spec = self.sanitized();
223 let start = spec.snap_value(value.start);
224 let end = spec.snap_value(value.end);
225 SliderRangeValue::new(start.min(end), start.max(end))
226 }
227
228 /// Moves one range thumb without allowing it to cross the other.
229 ///
230 /// Both the existing range and `next` are clamped and snapped first. When a
231 /// thumb reaches the other, the result is a zero-width range.
232 ///
233 /// # Examples
234 ///
235 /// ```
236 /// use ailloli_ui_core::{SliderRangeValue, SliderSpec, SliderThumb};
237 /// let spec = SliderSpec::new(0.0, 100.0);
238 /// let range = spec.set_range_thumb(SliderRangeValue::new(20.0, 80.0), SliderThumb::Start, 90.0);
239 /// assert_eq!(range, SliderRangeValue::new(80.0, 80.0));
240 /// ```
241 pub fn set_range_thumb(
242 self,
243 value: SliderRangeValue,
244 thumb: SliderThumb,
245 next: f32,
246 ) -> SliderRangeValue {
247 let spec = self.sanitized();
248 let value = spec.clamp_range_value(value);
249 let next = spec.snap_value(next);
250 match thumb {
251 SliderThumb::Start => SliderRangeValue::new(next.min(value.end), value.end),
252 SliderThumb::End => SliderRangeValue::new(value.start, next.max(value.start)),
253 }
254 }
255
256 /// Returns the nearest thumb for a target value.
257 ///
258 /// The range and target are clamped before comparison. Exact ties prefer
259 /// [`SliderThumb::End`].
260 ///
261 /// # Examples
262 ///
263 /// ```
264 /// use ailloli_ui_core::{SliderRangeValue, SliderSpec, SliderThumb};
265 /// let thumb = SliderSpec::new(0.0, 100.0).nearest_thumb(SliderRangeValue::new(20.0, 80.0), 50.0);
266 /// assert_eq!(thumb, SliderThumb::End);
267 /// ```
268 pub fn nearest_thumb(self, value: SliderRangeValue, target: f32) -> SliderThumb {
269 let value = self.clamp_range_value(value);
270 let target = self.clamp_value(target);
271 let start_dist = (target - value.start).abs();
272 let end_dist = (target - value.end).abs();
273 if start_dist < end_dist {
274 SliderThumb::Start
275 } else {
276 SliderThumb::End
277 }
278 }
279}
280
281/// Ordered pair of values controlled by a range slider.
282///
283/// # Examples
284///
285/// ```
286/// use ailloli_ui_core::SliderRangeValue;
287/// assert_eq!(SliderRangeValue::new(80.0, 20.0), SliderRangeValue { start: 20.0, end: 80.0 });
288/// ```
289#[derive(Debug, Clone, Copy, PartialEq)]
290pub struct SliderRangeValue {
291 /// Lower thumb value for finite, ordered inputs.
292 pub start: f32,
293 /// Upper thumb value for finite, ordered inputs.
294 pub end: f32,
295}
296
297impl SliderRangeValue {
298 /// Creates a range and swaps finite endpoints when `start > end`.
299 ///
300 /// Non-finite inputs are not sanitized; use [`SliderSpec::clamp_range_value`]
301 /// before interaction or rendering.
302 ///
303 /// # Examples
304 ///
305 /// ```
306 /// use ailloli_ui_core::SliderRangeValue;
307 /// assert_eq!(SliderRangeValue::new(8.0, 2.0), SliderRangeValue { start: 2.0, end: 8.0 });
308 /// ```
309 pub const fn new(start: f32, end: f32) -> Self {
310 if start <= end {
311 Self { start, end }
312 } else {
313 Self {
314 start: end,
315 end: start,
316 }
317 }
318 }
319}
320
321/// Thumb selected for a [`SliderRangeValue`] operation.
322///
323/// Possible values are [`SliderThumb::Start`] and [`SliderThumb::End`].
324///
325/// # Examples
326///
327/// ```
328/// use ailloli_ui_core::SliderThumb;
329/// assert_ne!(SliderThumb::Start, SliderThumb::End);
330/// ```
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum SliderThumb {
333 /// The lower/start thumb.
334 Start,
335 /// The upper/end thumb.
336 End,
337}
338
339#[cfg(test)]
340mod tests {
341 //! Covers domain sanitization, snapping, nudging, and non-crossing range thumbs.
342
343 use super::*;
344
345 #[test]
346 fn spec_sanitizes_invalid_min_max_and_step() {
347 assert_eq!(SliderSpec::new(10.0, 0.0).min, 0.0);
348 assert_eq!(SliderSpec::new(10.0, 0.0).max, 10.0);
349
350 let equal = SliderSpec::new(4.0, 4.0);
351 assert_eq!(equal.min, 4.0);
352 assert_eq!(equal.max, 5.0);
353
354 let fallback = SliderSpec {
355 min: f32::NAN,
356 max: 10.0,
357 step: Some(-2.0),
358 }
359 .sanitized();
360 assert_eq!(fallback, SliderSpec::default());
361 }
362
363 #[test]
364 fn clamp_and_snap_values() {
365 let spec = SliderSpec::new(0.0, 10.0).with_step(2.5);
366 assert_eq!(spec.clamp_value(-2.0), 0.0);
367 assert_eq!(spec.clamp_value(12.0), 10.0);
368 assert_eq!(spec.snap_value(3.1), 2.5);
369 assert_eq!(spec.snap_value(3.8), 5.0);
370 assert_eq!(spec.snap_value(9.9), 10.0);
371 }
372
373 #[test]
374 fn fraction_and_value_mapping_are_stable() {
375 let spec = SliderSpec::new(10.0, 30.0);
376 assert_eq!(spec.fraction_for_value(20.0), 0.5);
377 assert_eq!(spec.value_for_fraction(0.75), 25.0);
378 assert_eq!(spec.value_for_fraction(-1.0), 10.0);
379 assert_eq!(spec.value_for_fraction(2.0), 30.0);
380 }
381
382 #[test]
383 fn nudge_uses_step_or_span_fraction() {
384 let stepped = SliderSpec::new(0.0, 100.0).with_step(5.0);
385 assert_eq!(stepped.nudge_value(50.0, 1.0, false), 55.0);
386 assert_eq!(stepped.nudge_value(50.0, -1.0, true), 40.0);
387
388 let continuous = SliderSpec::new(0.0, 100.0);
389 assert_eq!(continuous.nudge_value(50.0, 1.0, false), 51.0);
390 }
391
392 #[test]
393 fn range_value_clamps_without_crossing() {
394 let spec = SliderSpec::new(0.0, 100.0).with_step(10.0);
395 assert_eq!(
396 spec.clamp_range_value(SliderRangeValue::new(83.0, 12.0)),
397 SliderRangeValue::new(10.0, 80.0)
398 );
399 assert_eq!(
400 spec.set_range_thumb(SliderRangeValue::new(20.0, 80.0), SliderThumb::Start, 95.0),
401 SliderRangeValue::new(80.0, 80.0)
402 );
403 assert_eq!(
404 spec.set_range_thumb(SliderRangeValue::new(20.0, 80.0), SliderThumb::End, 5.0),
405 SliderRangeValue::new(20.0, 20.0)
406 );
407 }
408
409 #[test]
410 fn nearest_thumb_prefers_end_on_tie() {
411 let spec = SliderSpec::new(0.0, 100.0);
412 assert_eq!(
413 spec.nearest_thumb(SliderRangeValue::new(20.0, 80.0), 30.0),
414 SliderThumb::Start
415 );
416 assert_eq!(
417 spec.nearest_thumb(SliderRangeValue::new(20.0, 80.0), 50.0),
418 SliderThumb::End
419 );
420 }
421}