Skip to main content

fret_ui_headless/
slider.rs

1use std::sync::Arc;
2
3#[derive(Debug, Clone)]
4pub struct SliderValuesUpdate {
5    pub values: Vec<f32>,
6    pub value_index_to_change: usize,
7}
8
9/// Formats a slider value for display in the semantics tree.
10///
11/// Radix exposes slider state via `aria-valuenow` (number) and `aria-valuetext` (optional). In
12/// Fret we currently store the value as a string in `SemanticsNode.value`, so we choose a stable,
13/// human-readable format.
14pub fn format_semantics_value(value: f32) -> Arc<str> {
15    if !value.is_finite() {
16        return Arc::from("NaN");
17    }
18    let rounded = value.round();
19    if (value - rounded).abs() < 1e-4 {
20        return Arc::from(format!("{}", rounded as i64).into_boxed_str());
21    }
22    Arc::from(format!("{value:.2}").into_boxed_str())
23}
24
25/// Normalizes a scalar value into the `[0, 1]` range.
26pub fn normalize_value(value: f32, min: f32, max: f32) -> f32 {
27    if !value.is_finite() || !min.is_finite() || !max.is_finite() {
28        return 0.0;
29    }
30    let span = max - min;
31    if !span.is_finite() || span.abs() <= f32::EPSILON {
32        return 0.0;
33    }
34    ((value - min) / span).clamp(0.0, 1.0)
35}
36
37fn decimal_count(step: f32) -> u32 {
38    let step = step.abs();
39    if !step.is_finite() || step <= 0.0 {
40        return 0;
41    }
42
43    let s = step.to_string();
44    let mut exp = 0i32;
45    let mut base = s.as_str();
46    if let Some(exp_at) = s.find(['e', 'E']) {
47        base = &s[..exp_at];
48        exp = s[exp_at + 1..].parse::<i32>().unwrap_or(0);
49    }
50
51    let base_decimals = base
52        .split_once('.')
53        .map(|(_, frac)| frac.len())
54        .unwrap_or(0) as i32;
55    let decimals = if exp < 0 {
56        base_decimals.saturating_add((-exp).min(38))
57    } else {
58        base_decimals.saturating_sub(exp)
59    };
60
61    decimals.max(0) as u32
62}
63
64fn round_to_step_decimals(value: f32, step: f32) -> f32 {
65    let decimals = decimal_count(step).min(10);
66    if decimals == 0 {
67        return value;
68    }
69    let factor = 10f64.powi(decimals as i32);
70    ((value as f64 * factor).round() / factor) as f32
71}
72
73/// Clamps and snaps a value to the nearest step (if step > 0).
74pub fn snap_value(value: f32, min: f32, max: f32, step: f32) -> f32 {
75    if !value.is_finite() || !min.is_finite() || !max.is_finite() {
76        return min;
77    }
78    let mut out = value.clamp(min, max);
79    if step.is_finite() && step > 0.0 {
80        let steps = ((out - min) / step).round();
81        out = (min + steps * step).clamp(min, max);
82        out = round_to_step_decimals(out, step);
83    }
84    out
85}
86
87/// Returns the next value array after updating the value at `at_index` and sorting ascending.
88///
89/// This mirrors Radix `getNextSortedValues`.
90pub fn next_sorted_values(prev_values: &[f32], next_value: f32, at_index: usize) -> Vec<f32> {
91    if prev_values.is_empty() {
92        return vec![next_value];
93    }
94
95    let mut next_values = prev_values.to_vec();
96    let index = at_index.min(next_values.len().saturating_sub(1));
97    next_values[index] = next_value;
98    next_values.sort_by(|a, b| a.total_cmp(b));
99    next_values
100}
101
102/// Given a `values` slice and `next_value`, returns the index of the closest current value.
103///
104/// This mirrors Radix `getClosestValueIndex`.
105pub fn closest_value_index(values: &[f32], next_value: f32) -> usize {
106    if values.len() <= 1 {
107        return 0;
108    }
109
110    let mut closest_index = 0;
111    let mut closest_distance = (values[0] - next_value).abs();
112    for (index, value) in values.iter().copied().enumerate().skip(1) {
113        let distance = (value - next_value).abs();
114        if distance < closest_distance {
115            closest_index = index;
116            closest_distance = distance;
117        }
118    }
119    closest_index
120}
121
122/// Returns the step delta between each adjacent value.
123///
124/// This mirrors Radix `getStepsBetweenValues`.
125pub fn steps_between_values(values: &[f32]) -> Vec<f32> {
126    values.windows(2).map(|pair| pair[1] - pair[0]).collect()
127}
128
129/// Verifies that all adjacent values are separated by at least `min_steps_between_values`.
130///
131/// This mirrors Radix `hasMinStepsBetweenValues`.
132pub fn has_min_steps_between_values(values: &[f32], min_steps_between_values: f32) -> bool {
133    if min_steps_between_values <= 0.0 {
134        return true;
135    }
136
137    let Some(min_delta) = steps_between_values(values).into_iter().reduce(f32::min) else {
138        return true;
139    };
140
141    min_delta >= min_steps_between_values
142}
143
144/// Updates a multi-thumb slider value array using Radix sorting + minimum distance rules.
145///
146/// Returns `None` when the update violates `min_steps_between_thumbs` (in step units).
147pub fn update_multi_thumb_values(
148    prev_values: &[f32],
149    raw_value: f32,
150    at_index: usize,
151    min: f32,
152    max: f32,
153    step: f32,
154    min_steps_between_thumbs: u32,
155) -> Option<SliderValuesUpdate> {
156    let step = if step.is_finite() && step > 0.0 {
157        step
158    } else {
159        1.0
160    };
161    let next_value = snap_value(raw_value, min, max, step);
162
163    let next_values = next_sorted_values(prev_values, next_value, at_index);
164    let min_steps_between_values = min_steps_between_thumbs as f32 * step;
165    if !has_min_steps_between_values(&next_values, min_steps_between_values) {
166        return None;
167    }
168
169    let value_index_to_change = next_values
170        .iter()
171        .position(|value| *value == next_value)
172        .unwrap_or(0);
173
174    Some(SliderValuesUpdate {
175        values: next_values,
176        value_index_to_change,
177    })
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn normalize_value_handles_degenerate_ranges() {
186        assert_eq!(normalize_value(5.0, 0.0, 0.0), 0.0);
187        assert_eq!(normalize_value(f32::NAN, 0.0, 1.0), 0.0);
188        assert_eq!(normalize_value(0.0, f32::NAN, 1.0), 0.0);
189    }
190
191    #[test]
192    fn normalize_value_clamps_to_unit_interval() {
193        assert_eq!(normalize_value(-1.0, 0.0, 10.0), 0.0);
194        assert_eq!(normalize_value(0.0, 0.0, 10.0), 0.0);
195        assert_eq!(normalize_value(5.0, 0.0, 10.0), 0.5);
196        assert_eq!(normalize_value(10.0, 0.0, 10.0), 1.0);
197        assert_eq!(normalize_value(999.0, 0.0, 10.0), 1.0);
198    }
199
200    #[test]
201    fn snap_value_snaps_to_nearest_step() {
202        assert_eq!(snap_value(0.0, 0.0, 10.0, 1.0), 0.0);
203        assert_eq!(snap_value(0.49, 0.0, 10.0, 1.0), 0.0);
204        assert_eq!(snap_value(0.51, 0.0, 10.0, 1.0), 1.0);
205        assert_eq!(snap_value(9.8, 0.0, 10.0, 1.0), 10.0);
206        assert_eq!(snap_value(5.3, 0.0, 10.0, 0.5), 5.5);
207    }
208
209    #[test]
210    fn snap_value_rounds_float_steps_like_radix() {
211        let v = snap_value(0.30000004, 0.0, 1.0, 0.1);
212        assert!((v - 0.3).abs() < 1e-6);
213
214        let v = snap_value(1.0000001, 0.0, 2.0, 0.25);
215        assert!((v - 1.0).abs() < 1e-6);
216    }
217
218    #[test]
219    fn format_semantics_value_uses_integer_when_close() {
220        assert_eq!(format_semantics_value(12.0).as_ref(), "12");
221        assert_eq!(format_semantics_value(12.00001).as_ref(), "12");
222    }
223
224    #[test]
225    fn closest_value_index_matches_radix_examples() {
226        assert_eq!(closest_value_index(&[10.0, 30.0], 25.0), 1);
227        assert_eq!(closest_value_index(&[10.0, 30.0], 11.0), 0);
228    }
229
230    #[test]
231    fn update_multi_thumb_values_sorts_and_updates_index() {
232        let update = update_multi_thumb_values(&[30.0, 10.0], 11.0, 1, 0.0, 100.0, 1.0, 0)
233            .expect("update should be allowed");
234        assert_eq!(update.values, vec![11.0, 30.0]);
235        assert_eq!(update.value_index_to_change, 0);
236    }
237
238    #[test]
239    fn update_multi_thumb_values_enforces_min_steps() {
240        let rejected = update_multi_thumb_values(&[10.0, 12.0], 11.0, 0, 0.0, 100.0, 1.0, 2);
241        assert!(rejected.is_none());
242
243        let allowed = update_multi_thumb_values(&[10.0, 13.0], 11.0, 0, 0.0, 100.0, 1.0, 2);
244        assert!(allowed.is_some());
245    }
246}