egui-elegance 0.8.0

Elegant, opinionated widgets for egui: buttons, inputs, selects, cards, tabs and more. Paired dark/light themes.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
//! Percentage slider — a 0–100% control whose central UI element is the
//! percentage value itself.
//!
//! [`PercentSlider`] differs from [`Slider`](crate::Slider) in three ways:
//!
//! 1. The value is always a 0–100 percentage (`f32`). Pair it with
//!    [`PercentSlider::callout_fmt`] when the percentage maps to a meaningful
//!    absolute quantity (a duration, a file size, a budget share) and the
//!    absolute value will surface in a callout while the user drags.
//! 2. The visual hierarchy puts the percentage value front and centre,
//!    rendered large in the top-right of the widget. An optional small
//!    label sits in the top-left.
//! 3. Quartile ticks (`0`, `25%`, `50%`, `75%`, `100%`) sit beneath the
//!    track so "what fraction of the total am I setting?" reads at a
//!    glance. Hide them with [`PercentSlider::show_ticks`] for compact
//!    layouts.
//!
//! Three snap modes are available, in order of specificity:
//! [`step`](PercentSlider::step) snaps to multiples of a fixed size
//! (`5.0` → 0, 5, 10, …); [`steps`](PercentSlider::steps) snaps to `n`
//! evenly-spaced positions including both endpoints (`steps(5)` → 0, 25,
//! 50, 75, 100); [`stops`](PercentSlider::stops) snaps to an explicit,
//! possibly non-uniform list of positions. When `steps` or `stops` is
//! set, the tick row renders at exactly those positions and the arrow
//! keys jump between them.

use egui::{
    Color32, CornerRadius, CursorIcon, Event, EventFilter, Key, Pos2, Rect, Response, Sense, Shape,
    Stroke, StrokeKind, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType,
};

use crate::theme::{placeholder_galley, with_alpha, Accent, Theme, BASELINE_FRAC};

/// A 0–100% slider whose central UI element is the percentage value itself.
///
/// ```no_run
/// # use elegance::PercentSlider;
/// # egui::__run_test_ui(|ui| {
/// let mut share = 45.0_f32;
/// ui.add(
///     PercentSlider::new(&mut share)
///         .label("Cache window")
///         .callout_fmt(|p| {
///             let mins = (p * 60.0 / 100.0).round() as i32;
///             format!("{mins} min")
///         }),
/// );
/// # });
/// ```
#[must_use = "Call `ui.add(...)` to render the widget."]
pub struct PercentSlider<'a> {
    value: &'a mut f32,
    label: Option<WidgetText>,
    accent: Accent,
    show_ticks: bool,
    step: Option<f32>,
    stops: Option<Vec<f32>>,
    decimals: usize,
    callout_fmt: Option<Box<dyn Fn(f32) -> String + 'a>>,
    desired_width: Option<f32>,
}

impl<'a> std::fmt::Debug for PercentSlider<'a> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PercentSlider")
            .field("accent", &self.accent)
            .field("show_ticks", &self.show_ticks)
            .field("step", &self.step)
            .field("stops", &self.stops)
            .field("decimals", &self.decimals)
            .field("desired_width", &self.desired_width)
            .finish()
    }
}

impl<'a> PercentSlider<'a> {
    /// Create a slider bound to `value`, clamped to `0.0..=100.0`.
    pub fn new(value: &'a mut f32) -> Self {
        Self {
            value,
            label: None,
            accent: Accent::Sky,
            show_ticks: true,
            step: None,
            stops: None,
            decimals: 0,
            callout_fmt: None,
            desired_width: None,
        }
    }

    /// Add a small label in the top-left of the header row.
    #[inline]
    pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
        self.label = Some(label.into());
        self
    }

    /// Pick the fill colour from one of the theme accents. Default: [`Accent::Sky`].
    #[inline]
    pub fn accent(mut self, accent: Accent) -> Self {
        self.accent = accent;
        self
    }

    /// Show or hide the quartile tick row beneath the track. Default: `true`.
    #[inline]
    pub fn show_ticks(mut self, show: bool) -> Self {
        self.show_ticks = show;
        self
    }

    /// Snap the value to multiples of `step` percentage points. Default:
    /// continuous. Common values: `5.0` for "round to 5%", `25.0` for
    /// quartile snap.
    ///
    /// Mutually exclusive with [`steps`](Self::steps) and
    /// [`stops`](Self::stops); calling this clears either of them.
    #[inline]
    pub fn step(mut self, step: f32) -> Self {
        self.step = Some(step);
        self.stops = None;
        self
    }

    /// Snap to `n` evenly-spaced positions, including both endpoints.
    /// `steps(2)` snaps to `{0, 100}`, `steps(5)` to `{0, 25, 50, 75, 100}`,
    /// `steps(7)` to seven positions whose spacing the widget computes for
    /// you (so the awkward `100 / 6` math stays out of caller code).
    ///
    /// When set, the tick row renders at exactly those positions and the
    /// arrow keys jump between adjacent stops. Values below `2` are
    /// promoted to `2`. Mutually exclusive with [`step`](Self::step) and
    /// [`stops`](Self::stops); calling this clears either of them.
    #[inline]
    pub fn steps(mut self, n: usize) -> Self {
        let n = n.max(2);
        let last = (n - 1) as f32;
        self.stops = Some((0..n).map(|i| (i as f32 / last) * 100.0).collect());
        self.step = None;
        self
    }

    /// Snap to an explicit, possibly non-uniform list of positions in
    /// `0.0..=100.0`. Out-of-range, `NaN`, and duplicate values are filtered
    /// out; the result is sorted ascending. If fewer than two valid
    /// positions remain, falls back to `[0.0, 100.0]`.
    ///
    /// When set, the tick row renders at exactly these positions and the
    /// arrow keys jump between adjacent stops. Mutually exclusive with
    /// [`step`](Self::step) and [`steps`](Self::steps); calling this clears
    /// either of them.
    pub fn stops(mut self, positions: impl IntoIterator<Item = f32>) -> Self {
        let mut v: Vec<f32> = positions
            .into_iter()
            .filter(|p| p.is_finite() && (0.0..=100.0).contains(p))
            .collect();
        v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
        v.dedup_by(|a, b| (*a - *b).abs() < f32::EPSILON);
        if v.len() < 2 {
            v = vec![0.0, 100.0];
        }
        self.stops = Some(v);
        self.step = None;
        self
    }

    /// Number of decimal places in the headline value. Default: `0`.
    #[inline]
    pub fn decimals(mut self, n: usize) -> Self {
        self.decimals = n;
        self
    }

    /// Supply a callback to format the *entire* drag-callout text. The
    /// callback receives the current percentage in `0.0..=100.0` and returns
    /// the string to render in the callout above the thumb while the user
    /// drags. When unset, no callout is shown.
    ///
    /// The callback has full control over the text — the widget does not
    /// prepend the percentage. Common patterns:
    ///
    /// ```ignore
    /// // Just the absolute quantity (the headline already shows the percent):
    /// .callout_fmt(|p| format!("{} min", (p * 60.0 / 100.0).round() as i32))
    ///
    /// // Percent and absolute together:
    /// .callout_fmt(|p| {
    ///     let mins = (p * 60.0 / 100.0).round() as i32;
    ///     format!("{}% \u{00B7} {} min", p.round() as i32, mins)
    /// })
    /// ```
    #[inline]
    pub fn callout_fmt(mut self, fmt: impl Fn(f32) -> String + 'a) -> Self {
        self.callout_fmt = Some(Box::new(fmt));
        self
    }

    /// Override the slider width. Defaults to `ui.available_width()`.
    #[inline]
    pub fn desired_width(mut self, width: f32) -> Self {
        self.desired_width = Some(width);
        self
    }
}

impl<'a> Widget for PercentSlider<'a> {
    fn ui(self, ui: &mut Ui) -> Response {
        let theme = Theme::current(ui.ctx());
        let p = &theme.palette;
        let t = &theme.typography;
        let accent_fill = p.accent_fill(self.accent);

        // Typography sizes for each row.
        let value_size = (t.heading * 1.5).round().max(20.0);
        let sign_size = t.label;
        let label_size = t.label;
        let tick_size = t.small;

        // Probe galley to discover the real header height; egui font metrics
        // vary slightly between platforms, so measure rather than guess.
        let probe = placeholder_galley(ui, "0", value_size, true, f32::INFINITY);
        let header_h = probe.size().y;

        let track_h: f32 = 6.0;
        let thumb_d: f32 = 14.0;
        let row_track_h = thumb_d.max(22.0);
        let header_gap: f32 = 8.0;
        let tick_gap: f32 = if self.show_ticks { 4.0 } else { 0.0 };
        let tick_row_h: f32 = if self.show_ticks {
            tick_size + 8.0
        } else {
            0.0
        };

        let total_h = header_h + header_gap + row_track_h + tick_gap + tick_row_h;
        let total_w = self
            .desired_width
            .unwrap_or_else(|| ui.available_width())
            .max(180.0);

        // Normalize and clamp the incoming value.
        let mut current = if self.value.is_nan() {
            0.0
        } else {
            *self.value
        };
        current = current.clamp(0.0, 100.0);

        let step = self.step.filter(|s| s.is_finite() && *s > 0.0);
        let stops = self.stops.as_deref();

        // Snap a raw 0..=100 percentage to whichever snap mode (if any) is
        // configured: explicit stops > step > continuous.
        let snap = |raw: f32| -> f32 {
            let v = if let Some(stops) = stops {
                nearest_stop(stops, raw)
            } else if let Some(s) = step {
                (raw / s).round() * s
            } else {
                raw
            };
            v.clamp(0.0, 100.0)
        };

        let (rect, mut response) =
            ui.allocate_exact_size(Vec2::new(total_w, total_h), Sense::click_and_drag());

        // Geometry: the thumb-centre travels between `track_left` and
        // `track_right`; the visible pill extends a thumb-radius further on
        // each side so the thumb sits cleanly inside it at the extremes.
        let thumb_pad = thumb_d * 0.5;
        let track_y = rect.min.y + header_h + header_gap + row_track_h * 0.5;
        let track_left = rect.min.x + thumb_pad;
        let track_right = rect.max.x - thumb_pad;
        let track_span = (track_right - track_left).max(1.0);
        let track_rect = Rect::from_min_max(
            Pos2::new(rect.min.x, track_y - track_h * 0.5),
            Pos2::new(rect.max.x, track_y + track_h * 0.5),
        );

        // Update value while the pointer button is held within the widget.
        if response.is_pointer_button_down_on() {
            if let Some(pos) = response.interact_pointer_pos() {
                let clamped_x = pos.x.clamp(track_left, track_right);
                let frac = ((clamped_x - track_left) / track_span).clamp(0.0, 1.0);
                let next = snap(frac * 100.0);
                if (next - current).abs() > f32::EPSILON {
                    current = next;
                    *self.value = current;
                    response.mark_changed();
                }
            }
        }

        // Keyboard nudges when the slider has focus. Left / Right step by
        // `step` (or 1 percentage point); Shift bumps to 10x; Home / End jump
        // to 0 / 100. Up / Down are intentionally left to egui's focus
        // navigation so the user can move between stacked controls vertically.
        //
        // `set_focus_lock_filter` claims horizontal arrows for this widget
        // while it's focused; without it, egui's focus system consumes
        // Left / Right for spatial navigation before the value handler below
        // ever sees them.
        if response.has_focus() {
            ui.memory_mut(|m| {
                m.set_focus_lock_filter(
                    response.id,
                    EventFilter {
                        horizontal_arrows: true,
                        ..Default::default()
                    },
                );
            });

            let small_step = step.unwrap_or(1.0);
            let big_step = step.map(|s| s * 10.0).unwrap_or(10.0);
            let events = ui.input(|input| input.events.clone());
            for ev in &events {
                if let Event::Key {
                    key,
                    pressed: true,
                    modifiers,
                    ..
                } = ev
                {
                    let nudge = if modifiers.shift {
                        big_step
                    } else {
                        small_step
                    };
                    // With discrete stops, arrows jump to the adjacent stop;
                    // Home / End snap to the first / last. Without stops,
                    // fall back to nudge-and-snap by `step` (or continuous).
                    let raw = match (stops, key) {
                        (Some(stops), Key::ArrowLeft) => adjacent_stop(stops, current, -1),
                        (Some(stops), Key::ArrowRight) => adjacent_stop(stops, current, 1),
                        (Some(stops), Key::Home) => stops.first().copied(),
                        (Some(stops), Key::End) => stops.last().copied(),
                        (None, Key::ArrowLeft) => Some(current - nudge),
                        (None, Key::ArrowRight) => Some(current + nudge),
                        (None, Key::Home) => Some(0.0),
                        (None, Key::End) => Some(100.0),
                        _ => None,
                    };
                    if let Some(next) = raw {
                        let next = snap(next);
                        if (next - current).abs() > f32::EPSILON {
                            current = next;
                            *self.value = current;
                            response.mark_changed();
                        }
                    }
                }
            }
        }

        if response.hovered() {
            ui.ctx().set_cursor_icon(CursorIcon::Grab);
        }
        if response.is_pointer_button_down_on() {
            ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
        }

        if ui.is_rect_visible(rect) {
            let painter = ui.painter().clone();

            // ---- Header row ----------------------------------------------
            let decimals = self.decimals;
            let num_text = format!("{current:.decimals$}");
            let num_galley = placeholder_galley(ui, &num_text, value_size, true, f32::INFINITY);
            let sign_galley = placeholder_galley(ui, "%", sign_size, false, f32::INFINITY);

            // Baseline-align the small "%" with the large numeric.
            let baseline_y = rect.min.y + num_galley.size().y * BASELINE_FRAC;
            let sign_x = rect.max.x - sign_galley.size().x;
            let num_x = sign_x - 2.0 - num_galley.size().x;
            painter.galley(Pos2::new(num_x, rect.min.y), num_galley, p.text);
            painter.galley(
                Pos2::new(sign_x, baseline_y - sign_galley.size().y * BASELINE_FRAC),
                sign_galley,
                p.text_muted,
            );

            if let Some(label) = &self.label {
                let label_wrap = (num_x - rect.min.x - 8.0).max(0.0);
                let label_galley =
                    placeholder_galley(ui, label.text(), label_size, false, label_wrap);
                let label_top = baseline_y - label_galley.size().y * BASELINE_FRAC;
                painter.galley(Pos2::new(rect.min.x, label_top), label_galley, p.text);
            }

            // ---- Track ----------------------------------------------------
            let frac = (current / 100.0).clamp(0.0, 1.0);
            let thumb_x = track_left + track_span * frac;
            let thumb_center = Pos2::new(thumb_x, track_y);

            let track_radius = CornerRadius::same((track_h * 0.5).round() as u8);

            painter.rect(
                track_rect,
                track_radius,
                p.input_bg,
                Stroke::new(1.0, p.border),
                StrokeKind::Inside,
            );
            if thumb_x > track_rect.min.x + 0.5 {
                let fill_rect = Rect::from_min_max(
                    Pos2::new(track_rect.min.x, track_rect.min.y),
                    Pos2::new(thumb_x, track_rect.max.y),
                );
                painter.rect_filled(fill_rect, track_radius, accent_fill);
            }

            // Faint 10% interior divisions, slightly stronger at the midpoint.
            // Painted on top of both the filled and unfilled portions so the
            // proportion reads at a glance like a battery indicator without
            // breaking the smooth-fill feel.
            let div_base = if p.is_dark {
                Color32::WHITE
            } else {
                Color32::BLACK
            };
            for i in 1..10 {
                let f = i as f32 / 10.0;
                let x = track_rect.min.x + track_rect.width() * f;
                let alpha = if i == 5 { 48 } else { 24 };
                painter.line_segment(
                    [
                        Pos2::new(x, track_rect.min.y + 1.5),
                        Pos2::new(x, track_rect.max.y - 1.5),
                    ],
                    Stroke::new(1.0, with_alpha(div_base, alpha)),
                );
            }

            // Focus / drag halo.
            if response.has_focus() || response.is_pointer_button_down_on() {
                painter.circle_filled(
                    thumb_center,
                    thumb_d * 0.5 + 4.0,
                    with_alpha(accent_fill, 55),
                );
            }
            // Thumb: pale fill, accent-coloured ring.
            painter.circle(
                thumb_center,
                thumb_d * 0.5,
                p.text,
                Stroke::new(2.0, accent_fill),
            );

            // ---- Ticks ---------------------------------------------------
            // With explicit stops: render a tick at each, all medium weight
            // (no major/minor distinction). Without stops: the default
            // quartile row, with 0 / 50% / 100% drawn slightly heavier than
            // 25% / 75%.
            if self.show_ticks {
                let tick_top_y = rect.min.y + header_h + header_gap + row_track_h + tick_gap;
                let ticks: Vec<(f32, String, bool)> = match stops {
                    Some(stops) => stops
                        .iter()
                        .map(|p| {
                            let label = if *p == 0.0 {
                                "0".to_string()
                            } else {
                                format!("{}%", p.round() as i32)
                            };
                            (p / 100.0, label, false)
                        })
                        .collect(),
                    None => vec![
                        (0.00, "0".to_string(), true),
                        (0.25, "25%".to_string(), false),
                        (0.50, "50%".to_string(), true),
                        (0.75, "75%".to_string(), false),
                        (1.00, "100%".to_string(), true),
                    ],
                };
                for (f, label, major) in &ticks {
                    let x = track_left + track_span * f;
                    let tick_h = if *major { 5.0 } else { 3.5 };
                    let tick_color = if *major { p.text_muted } else { p.border };
                    painter.line_segment(
                        [Pos2::new(x, tick_top_y), Pos2::new(x, tick_top_y + tick_h)],
                        Stroke::new(1.0, tick_color),
                    );
                    let g = placeholder_galley(ui, label, tick_size, false, f32::INFINITY);
                    let raw_x = x - g.size().x * 0.5;
                    let tx = raw_x.max(rect.min.x).min(rect.max.x - g.size().x);
                    let ty = tick_top_y + tick_h + 1.0;
                    painter.galley(Pos2::new(tx, ty), g, p.text_faint);
                }
            }

            // ---- Drag callout --------------------------------------------
            // The callout floats above the track during interaction. The
            // closure has full control over the text; the widget renders
            // exactly what it returns. Skip rendering when no formatter is
            // configured.
            if response.is_pointer_button_down_on() {
                if let Some(fmt) = &self.callout_fmt {
                    paint_callout(
                        ui,
                        &theme,
                        &painter,
                        thumb_center,
                        rect,
                        thumb_d,
                        fmt(current),
                    );
                }
            }
        }

        let label_str = self
            .label
            .as_ref()
            .map(|l| l.text().to_string())
            .unwrap_or_default();
        response.widget_info(|| WidgetInfo::labeled(WidgetType::Slider, true, &label_str));
        response
    }
}

fn paint_callout(
    ui: &egui::Ui,
    theme: &Theme,
    painter: &egui::Painter,
    thumb_center: Pos2,
    widget_rect: Rect,
    thumb_d: f32,
    text: String,
) {
    let p = &theme.palette;
    let t = &theme.typography;
    let g = placeholder_galley(ui, &text, t.label, false, f32::INFINITY);

    let pad_x: f32 = 9.0;
    let pad_y: f32 = 5.0;
    let tail_h: f32 = 5.0;
    let cw = g.size().x + 2.0 * pad_x;
    let ch = g.size().y + 2.0 * pad_y;

    // Centre over the thumb, clamping into the widget's horizontal extent
    // so the callout never escapes the parent layout.
    let half_w = cw * 0.5;
    let edge_pad = 4.0;
    let cx = thumb_center.x.clamp(
        widget_rect.min.x + half_w + edge_pad,
        widget_rect.max.x - half_w - edge_pad,
    );
    let cy_bottom = thumb_center.y - thumb_d * 0.5 - tail_h - 2.0;
    let crect = Rect::from_min_max(
        Pos2::new(cx - half_w, cy_bottom - ch),
        Pos2::new(cx + half_w, cy_bottom),
    );

    let fill = p.depth_tint(p.input_bg, 0.04);
    let border = p.border;

    painter.rect(
        crect,
        CornerRadius::same(5),
        fill,
        Stroke::new(1.0, border),
        StrokeKind::Inside,
    );

    // Tail: triangle pointing at the thumb. Drawn so its top edge sits flush
    // with the callout's bottom; a small fill cap hides the seam between the
    // bordered rect and the triangle.
    let tail_x = thumb_center
        .x
        .clamp(crect.min.x + tail_h + 2.0, crect.max.x - tail_h - 2.0);
    let tail_top_y = crect.max.y;
    let tip = Pos2::new(tail_x, tail_top_y + tail_h);
    let left = Pos2::new(tail_x - tail_h, tail_top_y);
    let right = Pos2::new(tail_x + tail_h, tail_top_y);
    painter.add(Shape::convex_polygon(
        vec![left, tip, right],
        fill,
        Stroke::new(1.0, border),
    ));
    // Hide the seam where the triangle meets the rect.
    painter.line_segment(
        [
            Pos2::new(tail_x - tail_h + 1.0, tail_top_y),
            Pos2::new(tail_x + tail_h - 1.0, tail_top_y),
        ],
        Stroke::new(1.5, fill),
    );

    painter.galley(
        Pos2::new(crect.min.x + pad_x, crect.min.y + pad_y),
        g,
        p.text,
    );
}

/// Snap `raw` to the nearest entry in `stops`. `stops` is assumed sorted and
/// non-empty; the builder maintains both invariants.
fn nearest_stop(stops: &[f32], raw: f32) -> f32 {
    let mut best = stops[0];
    let mut best_d = (raw - best).abs();
    for &s in &stops[1..] {
        let d = (raw - s).abs();
        if d < best_d {
            best_d = d;
            best = s;
        }
    }
    best
}

/// Return the stop immediately above (`dir > 0`) or below (`dir < 0`) `current`,
/// or `None` if `current` is already at the corresponding extreme. A small
/// epsilon lets the comparison treat a value that just snapped to a stop as
/// being "on" that stop rather than infinitesimally past it.
fn adjacent_stop(stops: &[f32], current: f32, dir: i32) -> Option<f32> {
    let eps = 0.001;
    if dir > 0 {
        stops.iter().copied().find(|&s| s > current + eps)
    } else {
        stops.iter().rev().copied().find(|&s| s < current - eps)
    }
}