Skip to main content

denise_ui/widgets/
slider.rs

1//! A value in a range, dragged or typed.
2
3use denise::Pen;
4use denise::{ElementState, InputEvent, KeyCode, Point, Rect, Role, Theme};
5
6use crate::widget::{Event, EventCtx, Handled, PaintCtx, VisualState, Widget};
7use crate::widgets::describe::{
8    Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
9};
10use crate::widgets::style::{focus_ring, interactive_pair};
11
12/// A horizontal slider over `min..=max`.
13///
14/// The message carries the value itself rather than a fraction, so a setpoint
15/// reads as one:
16///
17/// ```
18/// # use denise_ui::Slider;
19/// enum Message { Setpoint(f32) }
20/// Slider::new(16.0, 30.0, 21.5, Message::Setpoint).with_step(0.5);
21/// ```
22///
23/// # Dragging keeps the pointer even when it leaves
24///
25/// This is the whole reason a slider belongs in a toolkit rather than in every
26/// application. The tree already routes moves to the pressed widget wherever the
27/// pointer goes — but it **clears [`VisualState::PRESSED`] the moment the pointer
28/// leaves the widget**, because that is what makes a button's drag-off cancel.
29/// A slider that read its drag state from `PRESSED` would therefore stop tracking
30/// at exactly the edge it most needs to keep tracking past.
31///
32/// So the drag is a flag of this widget's own, set on press and cleared on
33/// release, and the pressed *look* comes from that flag rather than from the
34/// tree's. A drag that runs off either end clamps and keeps following, and comes
35/// back when the pointer does.
36///
37/// The one thing that flag assumes is that every press is eventually followed by
38/// a release. That is the input stream's contract, and every backend here honours
39/// it; there is no event that means "your capture was taken away".
40///
41/// # Pressing the track jumps
42///
43/// Rather than stepping towards the press. A panel is touch-first, and a finger
44/// landing on a point of the track means *go there* — there is no cursor
45/// hovering to suggest anything else. It also makes the whole track a target
46/// instead of just the knob, which matters when the knob is 20 pixels wide and
47/// the finger is not.
48#[derive(Clone, Debug)]
49pub struct Slider<M> {
50    min: f32,
51    max: f32,
52    value: f32,
53    step: Option<f32>,
54    dragging: bool,
55    message: Option<fn(f32) -> M>,
56    role: Role,
57}
58
59impl<M> Slider<M> {
60    /// A slider over `min..=max`, starting at `value`.
61    ///
62    /// A reversed range is put the right way round rather than refused: it is a
63    /// caller's argument order, not a state the widget has to model.
64    pub fn new(min: f32, max: f32, value: f32, message: fn(f32) -> M) -> Self {
65        let (min, max) = order(min, max);
66        Self {
67            min,
68            max,
69            value: clamp(min, max, value),
70            step: None,
71            dragging: false,
72            message: Some(message),
73            role: Role::Primary,
74        }
75    }
76
77    /// A slider that emits nothing, for a value the application reads rather
78    /// than reacts to.
79    pub fn inert(min: f32, max: f32, value: f32) -> Self {
80        let (min, max) = order(min, max);
81        Self {
82            min,
83            max,
84            value: clamp(min, max, value),
85            step: None,
86            dragging: false,
87            message: None,
88            role: Role::Primary,
89        }
90    }
91
92    /// Snaps to multiples of `step` from `min`.
93    ///
94    /// Off by default, because a continuous slider is what a brightness or a
95    /// volume wants. A step that is zero, negative or not a number is ignored
96    /// rather than dividing by it.
97    pub fn with_step(mut self, step: f32) -> Self {
98        self.step = (step.is_finite() && step > 0.0).then_some(step);
99        self.value = self.settle(self.value);
100        self
101    }
102
103    /// Sets the colour role of the filled portion and the knob.
104    pub fn with_role(mut self, role: Role) -> Self {
105        self.role = role;
106        self
107    }
108
109    /// The current value, always within the range and on a step if there is one.
110    #[inline]
111    pub const fn value(&self) -> f32 {
112        self.value
113    }
114
115    /// The range, in order.
116    #[inline]
117    pub const fn range(&self) -> (f32, f32) {
118        (self.min, self.max)
119    }
120
121    /// Sets the value **without emitting anything**, clamped and snapped.
122    ///
123    /// Silent for the same reason [`Checkbox::set_checked`] is: the message
124    /// reports what a person did.
125    ///
126    /// [`Checkbox::set_checked`]: super::Checkbox::set_checked
127    pub fn set_value(&mut self, value: f32) {
128        self.value = self.settle(value);
129    }
130
131    /// Sets the value, reporting whether it actually changed.
132    pub fn update(&mut self, value: f32) -> bool {
133        let value = self.settle(value);
134        let changed = value != self.value;
135        self.value = value;
136        changed
137    }
138
139    /// Replaces the range, keeping the value inside it.
140    pub fn set_range(&mut self, min: f32, max: f32) {
141        let (min, max) = order(min, max);
142        self.min = min;
143        self.max = max;
144        self.value = self.settle(self.value);
145    }
146
147    /// Replaces the colour role.
148    pub fn set_role(&mut self, role: Role) {
149        self.role = role;
150    }
151
152    /// Whether a drag is in progress.
153    #[inline]
154    pub const fn dragging(&self) -> bool {
155        self.dragging
156    }
157
158    /// `max - min`, never negative and never NaN.
159    #[inline]
160    fn span(&self) -> f32 {
161        self.max - self.min
162    }
163
164    /// Clamped into the range and put on a step.
165    fn settle(&self, value: f32) -> f32 {
166        let value = clamp(self.min, self.max, value);
167        let Some(step) = self.step else {
168            return value;
169        };
170        // Rounded without `f32::round`, which lives in `std` — the same idiom
171        // `Metrics::scaled` uses. `(value - min) / step` is never negative, so
172        // adding a half and truncating is a round.
173        let steps = ((value - self.min) / step + 0.5) as i32;
174        clamp(self.min, self.max, self.min + steps as f32 * step)
175    }
176
177    /// How far along the range the value sits, `0.0..=1.0`.
178    fn fraction(&self) -> f32 {
179        let span = self.span();
180        if span <= 0.0 {
181            return 0.0;
182        }
183        ((self.value - self.min) / span).clamp(0.0, 1.0)
184    }
185
186    /// One arrow-key press.
187    ///
188    /// A hundredth of the range when there is no step, which puts a full sweep
189    /// at a hundred presses — enough to be precise, few enough to be usable.
190    fn small_step(&self) -> f32 {
191        self.step.unwrap_or_else(|| self.span() / 100.0)
192    }
193
194    /// One `PageUp` or `PageDown`, ten times the small step.
195    fn large_step(&self) -> f32 {
196        self.small_step() * 10.0
197    }
198
199    /// Applies a new value, emitting only if it actually moved.
200    fn commit(&mut self, value: f32, ctx: &mut EventCtx<'_, M>) -> Handled {
201        let value = self.settle(value);
202        if value != self.value {
203            self.value = value;
204            if let Some(message) = self.message {
205                ctx.emit(message(value));
206            }
207        }
208        // Handled either way: the widget acted on the event even when the value
209        // was already where the press asked for.
210        Handled::Yes
211    }
212}
213
214/// The two ends, in order.
215#[inline]
216fn order(a: f32, b: f32) -> (f32, f32) {
217    if b < a { (b, a) } else { (a, b) }
218}
219
220/// Into the range, with NaN going to the low end.
221///
222/// NaN rather than a panic for the reason [`Progress`](super::Progress) gives at
223/// more length: the number comes from a caller's arithmetic, and a panic inside a
224/// paint loop on a kiosk is a black screen.
225#[inline]
226fn clamp(min: f32, max: f32, value: f32) -> f32 {
227    if value.is_nan() {
228        min
229    } else {
230        value.clamp(min, max)
231    }
232}
233
234/// The knob's diameter for a given rectangle and theme.
235fn knob_size(bounds: Rect, theme: &Theme) -> i32 {
236    theme
237        .metrics
238        .size_selector
239        .min(bounds.height)
240        .min(bounds.width)
241        .max(1)
242}
243
244/// The leftmost and rightmost the knob's **centre** may sit.
245///
246/// Inset by a radius at each end, so the knob stays inside the rectangle at both
247/// extremes rather than half-escaping it.
248fn travel(bounds: Rect, diameter: i32) -> (i32, i32) {
249    let radius = diameter / 2;
250    let left = bounds.x + radius;
251    let right = bounds.right() - diameter + radius;
252    (left, right.max(left))
253}
254
255/// Where along the travel a pointer at `x` is asking for, `0.0..=1.0`.
256fn fraction_at(bounds: Rect, diameter: i32, x: i32) -> f32 {
257    let (left, right) = travel(bounds, diameter);
258    if right <= left {
259        return 0.0;
260    }
261    ((x - left) as f32 / (right - left) as f32).clamp(0.0, 1.0)
262}
263
264impl<M: 'static> Widget<M> for Slider<M> {
265    fn describe(&self) -> Option<&dyn DynDescribe> {
266        Some(self)
267    }
268
269    fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
270        Some(self)
271    }
272    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
273        let bounds = ctx.bounds;
274        if bounds.is_empty() {
275            return;
276        }
277        let diameter = knob_size(bounds, ctx.theme);
278        let (left, right) = travel(bounds, diameter);
279        let centre = left + (((right - left) as f32) * self.fraction()) as i32;
280
281        // The pressed look comes from this widget's own flag, not from the tree's
282        // `PRESSED` — the tree clears that when the pointer leaves, and a knob
283        // that stops looking held while it is still being dragged is a lie about
284        // what is going on.
285        let state = ctx.state.set(VisualState::PRESSED, self.dragging);
286
287        let thickness = (diameter / 4).max(2);
288        let track = Rect::new(
289            bounds.x,
290            bounds.y + (bounds.height - thickness) / 2,
291            bounds.width,
292            thickness,
293        );
294        let radius = thickness / 2;
295
296        let (unfilled, _) = interactive_pair(ctx.theme, Role::Base300, state);
297        canvas.fill_rounded_rect(track, radius, unfilled);
298
299        let (fill, rim) = interactive_pair(ctx.theme, self.role, state);
300        let filled_to = centre - track.x;
301        if filled_to > 0 {
302            let filled = Rect::new(track.x, track.y, filled_to, track.height);
303            canvas.fill_rounded_rect(filled, radius.min(filled_to / 2), fill);
304        }
305
306        let knob = Rect::new(
307            centre - diameter / 2,
308            bounds.y + (bounds.height - diameter) / 2,
309            diameter,
310            diameter,
311        );
312        canvas.fill_rounded_rect(knob, diameter / 2, fill);
313        // A rim in the role's own content colour, which is the one pairing the
314        // theme guarantees. Without it the knob's left half vanishes into the
315        // filled track it is sitting on — same colour, no edge.
316        canvas.stroke_rounded_rect(knob, diameter / 2, ctx.theme.metrics.border, rim);
317
318        if state.contains(VisualState::FOCUSED) {
319            focus_ring(
320                ctx.theme,
321                bounds,
322                ctx.theme.radius(denise::Radius::Field),
323                canvas,
324            );
325        }
326    }
327
328    fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
329        // Everything the mapping needs, copied out: the closure must not borrow
330        // `self` or `ctx`, because `commit` needs both mutably.
331        let bounds = ctx.bounds;
332        let diameter = knob_size(bounds, ctx.theme);
333        let (min, span) = (self.min, self.span());
334        let at = move |point: Point| min + fraction_at(bounds, diameter, point.x) * span;
335
336        match event {
337            Event::Input(InputEvent::PointerButton {
338                state: ElementState::Down,
339                position,
340                ..
341            })
342            | Event::Input(InputEvent::TouchDown { position, .. }) => {
343                if !bounds.contains(*position) {
344                    return Handled::No;
345                }
346                self.dragging = true;
347                let value = at(*position);
348                self.commit(value, ctx)
349            }
350
351            // Delivered wherever the pointer is, because the tree routes moves to
352            // the pressed widget. Guarded by our own flag rather than by the
353            // bounds: leaving the rectangle is the case this exists for.
354            Event::Input(InputEvent::PointerMoved { position })
355            | Event::Input(InputEvent::TouchMoved { position, .. }) => {
356                if !self.dragging {
357                    return Handled::No;
358                }
359                let value = at(*position);
360                self.commit(value, ctx)
361            }
362
363            Event::Input(InputEvent::PointerButton {
364                state: ElementState::Up,
365                ..
366            })
367            | Event::Input(InputEvent::TouchUp { .. }) => {
368                if !self.dragging {
369                    return Handled::No;
370                }
371                self.dragging = false;
372                Handled::Yes
373            }
374
375            Event::Input(InputEvent::Key {
376                code,
377                state: ElementState::Down,
378                ..
379            }) if ctx.state.contains(VisualState::FOCUSED) => {
380                let value = match code {
381                    KeyCode::ArrowLeft | KeyCode::ArrowDown => self.value - self.small_step(),
382                    KeyCode::ArrowRight | KeyCode::ArrowUp => self.value + self.small_step(),
383                    KeyCode::PageDown => self.value - self.large_step(),
384                    KeyCode::PageUp => self.value + self.large_step(),
385                    KeyCode::Home => self.min,
386                    KeyCode::End => self.max,
387                    _ => return Handled::No,
388                };
389                self.commit(value, ctx)
390            }
391
392            _ => Handled::No,
393        }
394    }
395
396    fn accepts_pointer(&self) -> bool {
397        true
398    }
399
400    fn focusable(&self) -> bool {
401        true
402    }
403}
404
405impl<M> Describe for Slider<M> {
406    const KIND: &'static str = "slider";
407    const DOC: &'static str = "A value in a range, dragged along a track.";
408    const GROUP: Group = Group::Input;
409    const ICON: &'static denise::icon::Icon = &super::icons::SLIDER;
410
411    // The bounds on `value` and `step` are the widest a float can be rather
412    // than a guess: what an editor should really offer is `min..=max`, and
413    // those are themselves properties here, so an inspector reads them from the
414    // widget instead of from a constant written before the range was known.
415    const PROPERTIES: &'static [Property] = &[
416        Property::new(
417            "min",
418            PropertyKind::Float {
419                min: f32::MIN,
420                max: f32::MAX,
421            },
422            "The low end of the range.",
423        ),
424        Property::new(
425            "max",
426            PropertyKind::Float {
427                min: f32::MIN,
428                max: f32::MAX,
429            },
430            "The high end of the range; a range given the wrong way round is put in order rather than refused.",
431        ),
432        Property::new(
433            "value",
434            PropertyKind::Float {
435                min: f32::MIN,
436                max: f32::MAX,
437            },
438            "Where the knob sits, within the range and on a step if there is one.",
439        ),
440        Property::new(
441            "step",
442            PropertyKind::Float {
443                min: 0.0,
444                max: f32::MAX,
445            },
446            "Snaps to multiples of this from `min`; continuous without it.",
447        ),
448        Property::new(
449            "on-change",
450            PropertyKind::Message(Payload::Number),
451            "Emitted with the new value when a person moves the knob.",
452        ),
453        Property::new(
454            "role",
455            PropertyKind::Enum(ROLES),
456            "Colour of the filled portion and the knob.",
457        ),
458    ];
459
460    fn get(&self, name: &str) -> Option<Value> {
461        Some(match name {
462            "min" => Value::Float(self.min),
463            "max" => Value::Float(self.max),
464            "value" => Value::Float(self.value),
465            // A continuous slider has no step to report, so a file that never
466            // set one does not grow one.
467            "step" => Value::Float(self.step?),
468            "role" => Value::role(self.role),
469            // The message is the application's own type; see the `describe`
470            // module documentation.
471            _ => return None,
472        })
473    }
474
475    fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
476        match name {
477            // Both ends go through `set_range`, which puts them in order and
478            // settles the value afterwards — so a form that writes `max` before
479            // `min` ends up in the same place as one that writes them the other
480            // way round.
481            "min" => self.set_range(value.as_float()?, self.max),
482            "max" => self.set_range(self.min, value.as_float()?),
483            "value" => self.set_value(value.as_float()?),
484            "step" => {
485                // `with_step`'s rule: a step that is zero, negative or not a
486                // number is no step rather than something to divide by. Setting
487                // the value again is what puts it on the new grid.
488                let step = value.as_float()?;
489                self.step = (step.is_finite() && step > 0.0).then_some(step);
490                self.set_value(self.value);
491            }
492            "role" => self.role = value.as_role()?,
493            "on-change" => return Err(Mismatch::Supplied),
494            _ => return Err(Mismatch::Unknown),
495        }
496        Ok(())
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use denise::theme;
504
505    fn slider() -> Slider<f32> {
506        Slider::new(0.0, 100.0, 50.0, |value| value)
507    }
508
509    /// A caller's argument order is not a state the widget has to model.
510    #[test]
511    fn a_reversed_range_is_put_the_right_way_round() {
512        let slider: Slider<f32> = Slider::new(30.0, 16.0, 21.0, |v| v);
513        assert_eq!(slider.range(), (16.0, 30.0));
514        assert_eq!(slider.value(), 21.0);
515    }
516
517    /// The degenerate range. Every fraction is zero and nothing divides by it.
518    #[test]
519    fn an_empty_range_pins_the_value_and_does_not_divide_by_zero() {
520        let mut slider: Slider<f32> = Slider::new(5.0, 5.0, 5.0, |v| v);
521        assert_eq!(slider.fraction(), 0.0);
522        slider.set_value(99.0);
523        assert_eq!(slider.value(), 5.0);
524        assert!(slider.small_step().is_finite() || slider.small_step() == 0.0);
525    }
526
527    /// NaN goes to the low end rather than through the arithmetic.
528    #[test]
529    fn a_value_that_is_not_a_number_lands_at_the_low_end() {
530        let done = core::hint::black_box(0.0f32);
531        let total = core::hint::black_box(0.0f32);
532        let mut slider = slider();
533        slider.set_value(done / total);
534        assert_eq!(slider.value(), 0.0);
535        assert_eq!(slider.fraction(), 0.0);
536    }
537
538    #[test]
539    fn values_outside_the_range_are_clamped() {
540        let mut slider = slider();
541        slider.set_value(1e9);
542        assert_eq!(slider.value(), 100.0);
543        slider.set_value(-1e9);
544        assert_eq!(slider.value(), 0.0);
545        slider.set_value(f32::NEG_INFINITY);
546        assert_eq!(slider.value(), 0.0);
547    }
548
549    /// Snapping lands on multiples of the step measured **from `min`**, not from
550    /// zero — a 16..30 slider stepping by 0.5 should offer 21.5, and it would
551    /// offer nothing useful if the grid started somewhere else.
552    #[test]
553    fn a_step_snaps_to_multiples_from_the_low_end() {
554        let mut slider: Slider<f32> = Slider::new(16.0, 30.0, 21.0, |v| v).with_step(0.5);
555        slider.set_value(21.3);
556        assert_eq!(slider.value(), 21.5);
557        slider.set_value(21.2);
558        assert_eq!(slider.value(), 21.0);
559
560        // And never off the end, however the rounding falls.
561        slider.set_value(29.9);
562        assert!(slider.value() <= 30.0);
563        slider.set_value(16.1);
564        assert!(slider.value() >= 16.0);
565    }
566
567    /// A step that cannot divide anything is ignored rather than dividing by it.
568    #[test]
569    fn a_nonsense_step_is_ignored() {
570        for step in [0.0, -1.0, f32::NAN, f32::INFINITY] {
571            let slider: Slider<f32> = Slider::new(0.0, 10.0, 3.3, |v| v).with_step(step);
572            assert_eq!(slider.value(), 3.3, "step {step} should have been ignored");
573        }
574    }
575
576    /// The knob stays inside its rectangle at both ends of the travel, which is
577    /// the whole of what the inset arithmetic has to get right.
578    #[test]
579    fn the_knob_stays_inside_the_rectangle_across_the_whole_travel() {
580        for width in [1, 2, 21, 200, 1920] {
581            let bounds = Rect::new(7, 3, width, 40);
582            let diameter = knob_size(bounds, &theme::DARK);
583            let (left, right) = travel(bounds, diameter);
584            assert!(right >= left, "width {width}: travel is inverted");
585            for centre in [left, (left + right) / 2, right] {
586                let knob = Rect::new(centre - diameter / 2, bounds.y, diameter, diameter);
587                assert!(
588                    knob.x >= bounds.x && knob.right() <= bounds.right(),
589                    "width {width}: knob at {centre} escaped {bounds:?}"
590                );
591            }
592        }
593    }
594
595    /// A pointer dragged past either end clamps rather than wrapping. Wrapping a
596    /// volume from full to silent because a finger slid too far is the failure
597    /// this pins.
598    #[test]
599    fn a_pointer_past_either_end_clamps_rather_than_wrapping() {
600        let bounds = Rect::new(10, 0, 200, 40);
601        let diameter = knob_size(bounds, &theme::DARK);
602
603        assert_eq!(fraction_at(bounds, diameter, -100_000), 0.0);
604        assert_eq!(fraction_at(bounds, diameter, 100_000), 1.0);
605        assert_eq!(fraction_at(bounds, diameter, bounds.x - 1), 0.0);
606        assert_eq!(fraction_at(bounds, diameter, bounds.right() + 1), 1.0);
607
608        // And it is monotonic in between, so a drag never jumps backwards.
609        let mut previous = -1.0;
610        for x in bounds.x..bounds.right() {
611            let fraction = fraction_at(bounds, diameter, x);
612            assert!(fraction >= previous, "went backwards at x {x}");
613            previous = fraction;
614        }
615    }
616
617    /// A rectangle too narrow to have any travel must still answer, rather than
618    /// dividing by a zero-length range.
619    #[test]
620    fn a_rectangle_with_no_travel_answers_zero() {
621        let bounds = Rect::new(0, 0, 4, 40);
622        let diameter = knob_size(bounds, &theme::DARK);
623        assert_eq!(fraction_at(bounds, diameter, 2), 0.0);
624        assert_eq!(fraction_at(bounds, diameter, 1000), 0.0);
625    }
626
627    /// The step sizes are the keyboard contract: a hundred presses for a sweep,
628    /// ten pages.
629    #[test]
630    fn the_keyboard_steps_are_a_hundredth_and_a_tenth_of_the_range() {
631        let slider = slider();
632        assert_eq!(slider.small_step(), 1.0);
633        assert_eq!(slider.large_step(), 10.0);
634
635        // With a step, arrows move by exactly one of them.
636        let stepped: Slider<f32> = Slider::new(0.0, 10.0, 0.0, |v| v).with_step(0.25);
637        assert_eq!(stepped.small_step(), 0.25);
638        assert_eq!(stepped.large_step(), 2.5);
639    }
640
641    /// The knob's rim has to separate it from the filled track it sits on — same
642    /// colour otherwise, and no edge. `interactive_pair` is what guarantees it.
643    #[test]
644    fn the_knob_rim_is_visible_against_the_knob_in_every_theme_and_state() {
645        use denise::theme::{AA_LARGE, contrast_x100};
646
647        for theme in Theme::BUILT_IN {
648            for state in [
649                VisualState::NONE,
650                VisualState::HOVERED,
651                VisualState::PRESSED,
652                VisualState::DISABLED,
653                VisualState::FOCUSED,
654            ] {
655                let (fill, rim) = interactive_pair(&theme, Role::Primary, state);
656                let ratio = contrast_x100(fill, rim);
657                assert!(
658                    ratio >= AA_LARGE,
659                    "{} {state:?}: rim against knob is {ratio}, floor is {AA_LARGE}",
660                    theme.name
661                );
662            }
663        }
664    }
665}