Skip to main content

iced_cube/primitives/
slider.rs

1//! Sliders for choosing a number within a range.
2
3use std::ops::RangeInclusive;
4
5use iced::keyboard::key::Named;
6use iced::widget::slider::{Handle, HandleShape, Rail, Status};
7use iced::widget::{self, column, row, space, text};
8use iced::{Background, Border, Color, Element, Length, Theme};
9
10use crate::inert::inert;
11use crate::keys::{self, Chord, Keymap};
12use crate::theme::{Tokens, fade, mix, radius, space as spacing, text_size};
13
14/// Radius of the handle in logical pixels.
15pub const HANDLE_RADIUS: f32 = 8.0;
16
17const RAIL_WIDTH: f32 = 6.0;
18
19/// Numbers a slider can hold, such as `f32`, `f64`, `u8` or `i32`.
20pub trait Value:
21    Copy + PartialOrd + From<u8> + Into<f64> + num_traits::FromPrimitive + ToString
22{
23}
24
25impl<T> Value for T where
26    T: Copy + PartialOrd + From<u8> + Into<f64> + num_traits::FromPrimitive + ToString
27{
28}
29
30/// A slider builder. Convert it into an [`Element`] to render.
31///
32/// A slider without a message is rendered disabled.
33pub struct Slider<'a, T, Message> {
34    range: RangeInclusive<T>,
35    value: T,
36    step: Option<T>,
37    label: Option<text::Fragment<'a>>,
38    format: Option<Box<dyn Fn(T) -> String + 'a>>,
39    width: Length,
40    on_change: Option<Box<dyn Fn(T) -> Message + 'a>>,
41    on_release: Option<Message>,
42}
43
44impl<T: std::fmt::Debug, Message> std::fmt::Debug for Slider<'_, T, Message> {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("Slider")
47            .field("range", &self.range)
48            .field("value", &self.value)
49            .field("step", &self.step)
50            .field("label", &self.label)
51            .field("shows_value", &self.format.is_some())
52            .field("enabled", &self.on_change.is_some())
53            .finish()
54    }
55}
56
57/// Creates a slider over `range` showing `value`. The value is clamped to
58/// the range, and a reversed range is treated as its forward equivalent.
59pub fn slider<'a, T: Value, Message>(range: RangeInclusive<T>, value: T) -> Slider<'a, T, Message> {
60    let range = normalise(range);
61    Slider {
62        value: clamp(value, &range),
63        range,
64        step: None,
65        label: None,
66        format: None,
67        width: Length::Fill,
68        on_change: None,
69        on_release: None,
70    }
71}
72
73impl<'a, T: Value, Message> Slider<'a, T, Message> {
74    /// Sets the increment the value snaps to. Zero or negative steps are ignored.
75    pub fn step(mut self, step: T) -> Self {
76        self.step = valid_step(step, &self.range);
77        self
78    }
79
80    /// Shows a label above the slider.
81    pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self {
82        self.label = Some(label.into_fragment());
83        self
84    }
85
86    /// Shows the current value above the slider, as plain text.
87    pub fn show_value(self) -> Self {
88        self.format_value(|value: T| value.to_string())
89    }
90
91    /// Shows the current value above the slider, formatted by `format`.
92    pub fn format_value(mut self, format: impl Fn(T) -> String + 'a) -> Self {
93        self.format = Some(Box::new(format));
94        self
95    }
96
97    pub fn width(mut self, width: impl Into<Length>) -> Self {
98        self.width = width.into();
99        self
100    }
101
102    /// Sets the message for every change while dragging.
103    pub fn on_change(mut self, on_change: impl Fn(T) -> Message + 'a) -> Self {
104        self.on_change = Some(Box::new(on_change));
105        self
106    }
107
108    /// Sets a message emitted once the handle is released.
109    pub fn on_release(mut self, message: Message) -> Self {
110        self.on_release = Some(message);
111        self
112    }
113
114    pub fn value(&self) -> T {
115        self.value
116    }
117
118    pub fn range(&self) -> &RangeInclusive<T> {
119        &self.range
120    }
121
122    pub fn is_enabled(&self) -> bool {
123        self.on_change.is_some()
124    }
125}
126
127impl<'a, T, Message> From<Slider<'a, T, Message>> for Element<'a, Message>
128where
129    T: Value + 'a,
130    Message: Clone + 'a,
131{
132    fn from(slider: Slider<'a, T, Message>) -> Self {
133        let enabled = slider.is_enabled();
134        let value = slider.value;
135        let step = slider.step;
136
137        let track: Element<'a, Message> = match slider.on_change {
138            Some(on_change) => {
139                let mut track = widget::slider(slider.range, value, on_change)
140                    .height(HANDLE_RADIUS * 2.0)
141                    .style(|theme, status| style(&Tokens::of(theme), status, true));
142                if let Some(step) = step {
143                    track = track.step(step);
144                }
145                if let Some(message) = slider.on_release {
146                    track = track.on_release(message);
147                }
148                track.into()
149            }
150            None => {
151                let mut track = widget::slider(slider.range, value, |_| ())
152                    .height(HANDLE_RADIUS * 2.0)
153                    .style(|theme, status| style(&Tokens::of(theme), status, false));
154                if let Some(step) = step {
155                    track = track.step(step);
156                }
157                inert(track.into())
158            }
159        };
160
161        let shown = slider.format.map(|format| format(value));
162        if slider.label.is_none() && shown.is_none() {
163            return widget::container(track).width(slider.width).into();
164        }
165
166        let caption = move |content: text::Fragment<'a>, muted: bool| {
167            text(content)
168                .size(text_size::SM)
169                .style(move |theme: &Theme| {
170                    let tokens = Tokens::of(theme);
171                    let colour = if muted {
172                        tokens.muted_foreground
173                    } else {
174                        tokens.foreground
175                    };
176                    text::Style {
177                        color: Some(if enabled { colour } else { fade(colour, 0.5) }),
178                    }
179                })
180        };
181
182        let mut header = row![].spacing(spacing::SM);
183        if let Some(label) = slider.label {
184            header = header.push(caption(label, false));
185        }
186        header = header.push(space().width(Length::Fill));
187        if let Some(shown) = shown {
188            header = header.push(caption(shown.into(), true));
189        }
190
191        column![header, track]
192            .spacing(spacing::SM)
193            .width(slider.width)
194            .into()
195    }
196}
197
198/// Orders the bounds of a range so the start is never above the end.
199pub fn normalise<T: PartialOrd + Copy>(range: RangeInclusive<T>) -> RangeInclusive<T> {
200    let (start, end) = (*range.start(), *range.end());
201    if start > end { end..=start } else { range }
202}
203
204/// Clamps `value` into `range`.
205pub fn clamp<T: PartialOrd + Copy>(value: T, range: &RangeInclusive<T>) -> T {
206    if value < *range.start() {
207        return *range.start();
208    }
209    if value > *range.end() {
210        return *range.end();
211    }
212    value
213}
214
215/// Returns `step` if it is positive, capped at the width of `range`.
216pub fn valid_step<T: Value>(step: T, range: &RangeInclusive<T>) -> Option<T> {
217    if step <= T::from(0) {
218        return None;
219    }
220    let span: f64 = (*range.end()).into() - (*range.start()).into();
221    if span > 0.0 && step.into() > span {
222        return T::from_f64(span);
223    }
224    Some(step)
225}
226
227/// Moves `value` by `steps` whole steps within `range`, landing on the
228/// step grid that starts at the range's start. Without a valid `step` it
229/// moves by 1, as the widget does. The range end is always reachable.
230pub fn stepped<T: Value>(value: T, range: RangeInclusive<T>, step: Option<T>, steps: i32) -> T {
231    let range = normalise(range);
232    let size: f64 = step
233        .and_then(|step| valid_step(step, &range))
234        .unwrap_or_else(|| T::from(1))
235        .into();
236    let start: f64 = (*range.start()).into();
237    let end: f64 = (*range.end()).into();
238    let current: f64 = clamp(value, &range).into();
239    if steps == 0 || size <= 0.0 {
240        return clamp(value, &range);
241    }
242
243    // Snap an off-grid value to the grid point in the direction of travel.
244    let position = (current - start) / size;
245    let tolerance = 1e-9;
246    let index = if steps > 0 {
247        (position + tolerance).floor() + f64::from(steps)
248    } else {
249        (position - tolerance).ceil() + f64::from(steps)
250    };
251    let target = (start + index * size).clamp(start, end);
252    T::from_f64(target).map_or(value, |target| clamp(target, &range))
253}
254
255/// What a slider keyboard shortcut does.
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
257pub enum Action {
258    Increase,
259    Decrease,
260    Min,
261    Max,
262}
263
264impl Action {
265    /// The value after this action, to send through the slider's
266    /// `on_change` message. `step` is the one given to [`Slider::step`].
267    pub fn apply<T: Value>(self, value: T, range: RangeInclusive<T>, step: Option<T>) -> T {
268        let range = normalise(range);
269        match self {
270            Action::Increase => stepped(value, range, step, 1),
271            Action::Decrease => stepped(value, range, step, -1),
272            Action::Min => *range.start(),
273            Action::Max => *range.end(),
274        }
275    }
276}
277
278impl keys::Action for Action {
279    const ALL: &'static [Self] = &[Action::Increase, Action::Decrease, Action::Min, Action::Max];
280
281    fn defaults() -> Keymap<Self> {
282        default_keymap()
283    }
284
285    fn name(self) -> &'static str {
286        match self {
287            Action::Increase => "Increase",
288            Action::Decrease => "Decrease",
289            Action::Min => "Min",
290            Action::Max => "Max",
291        }
292    }
293
294    fn description(self) -> &'static str {
295        match self {
296            Action::Increase => "Raises the value by one step.",
297            Action::Decrease => "Lowers the value by one step.",
298            Action::Min => "Sets the value to the start of the range.",
299            Action::Max => "Sets the value to the end of the range.",
300        }
301    }
302}
303
304/// The default slider shortcuts:
305///
306/// | Keys | Action |
307/// | --- | --- |
308/// | `ArrowRight`, `ArrowUp` | [`Action::Increase`] |
309/// | `ArrowLeft`, `ArrowDown` | [`Action::Decrease`] |
310/// | `Home` | [`Action::Min`] |
311/// | `End` | [`Action::Max`] |
312///
313/// Shortcuts are app-wide, so an app with several sliders routes them to
314/// the one it considers current.
315pub fn default_keymap() -> Keymap<Action> {
316    Keymap::new()
317        .bind(Chord::named(Named::ArrowRight), Action::Increase)
318        .bind(Chord::named(Named::ArrowUp), Action::Increase)
319        .bind(Chord::named(Named::ArrowLeft), Action::Decrease)
320        .bind(Chord::named(Named::ArrowDown), Action::Decrease)
321        .bind(Chord::named(Named::Home), Action::Min)
322        .bind(Chord::named(Named::End), Action::Max)
323}
324
325/// Rail and handle colours of a slider.
326#[derive(Debug, Clone, Copy, PartialEq)]
327pub struct Colours {
328    pub filled: Color,
329    pub empty: Color,
330    pub handle: Color,
331    pub handle_border: Color,
332}
333
334/// Resolves the colours of a slider for an interaction status.
335pub fn colours(tokens: &Tokens, status: Status, enabled: bool) -> Colours {
336    let handle_border = match status {
337        Status::Active => tokens.primary,
338        Status::Hovered => mix(tokens.primary, tokens.background, 0.15),
339        Status::Dragged => mix(tokens.primary, tokens.background, 0.3),
340    };
341    let colours = Colours {
342        filled: tokens.primary,
343        empty: mix(tokens.muted, tokens.border, 0.5),
344        handle: tokens.background,
345        handle_border,
346    };
347
348    if enabled {
349        return colours;
350    }
351    Colours {
352        filled: fade(colours.filled, 0.5),
353        empty: fade(colours.empty, 0.5),
354        handle: colours.handle,
355        handle_border: fade(tokens.primary, 0.5),
356    }
357}
358
359/// The iced slider style for a status.
360pub fn style(tokens: &Tokens, status: Status, enabled: bool) -> widget::slider::Style {
361    let colours = colours(tokens, status, enabled);
362    let border_width = match (enabled, status) {
363        (true, Status::Hovered | Status::Dragged) => 2.5,
364        _ => 1.5,
365    };
366    widget::slider::Style {
367        rail: Rail {
368            backgrounds: (
369                Background::Color(colours.filled),
370                Background::Color(colours.empty),
371            ),
372            width: RAIL_WIDTH,
373            border: Border::default().rounded(radius::FULL),
374        },
375        handle: Handle {
376            shape: HandleShape::Circle {
377                radius: HANDLE_RADIUS,
378            },
379            background: Background::Color(colours.handle),
380            border_width,
381            border_color: colours.handle_border,
382        },
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::theme::{dark, light};
390
391    const STATES: [Status; 3] = [Status::Active, Status::Hovered, Status::Dragged];
392
393    #[test]
394    fn default_builder_fills_width_and_is_disabled() {
395        let s: Slider<'_, f32, ()> = slider(0.0..=1.0, 0.5);
396        assert_eq!(s.width, Length::Fill);
397        assert!(s.step.is_none());
398        assert!(s.format.is_none());
399        assert!(!s.is_enabled());
400        assert!(s.on_change(|_| ()).is_enabled());
401    }
402
403    #[test]
404    fn value_is_clamped_to_the_range() {
405        let s: Slider<'_, i32, ()> = slider(0..=10, 42);
406        assert_eq!(s.value(), 10);
407        let s: Slider<'_, i32, ()> = slider(0..=10, -3);
408        assert_eq!(s.value(), 0);
409        let s: Slider<'_, i32, ()> = slider(0..=10, 7);
410        assert_eq!(s.value(), 7);
411    }
412
413    #[test]
414    fn reversed_range_is_normalised() {
415        let s: Slider<'_, f64, ()> = slider(10.0..=0.0, 4.0);
416        assert_eq!(s.range(), &(0.0..=10.0));
417        assert_eq!(s.value(), 4.0);
418    }
419
420    #[test]
421    fn non_positive_steps_are_ignored() {
422        assert_eq!(valid_step(0.0_f32, &(0.0..=1.0)), None);
423        assert_eq!(valid_step(-0.5_f32, &(0.0..=1.0)), None);
424        assert_eq!(valid_step(0.25_f32, &(0.0..=1.0)), Some(0.25));
425    }
426
427    #[test]
428    fn step_is_capped_at_the_range_width() {
429        assert_eq!(valid_step(50_u8, &(0..=10)), Some(10));
430        assert_eq!(valid_step(5_i32, &(3..=3)), Some(5));
431    }
432
433    #[test]
434    fn builder_step_uses_validation() {
435        let s: Slider<'_, f32, ()> = slider(0.0..=1.0, 0.5).step(0.0);
436        assert!(s.step.is_none());
437        let s: Slider<'_, f32, ()> = slider(0.0..=100.0, 0.5).step(5.0);
438        assert_eq!(s.step, Some(5.0));
439    }
440
441    #[test]
442    fn stepped_moves_along_the_grid() {
443        assert_eq!(stepped(40_u8, 0..=100, None, 1), 41);
444        assert_eq!(stepped(40_u8, 0..=100, Some(5), 1), 45);
445        assert_eq!(stepped(40_u8, 0..=100, Some(5), -2), 30);
446        assert_eq!(stepped(0.5_f32, 0.0..=1.0, Some(0.25), 1), 0.75);
447        assert_eq!(stepped(7_i32, 0..=10, Some(2), 0), 7);
448    }
449
450    #[test]
451    fn stepped_snaps_off_grid_values_in_the_direction_of_travel() {
452        assert_eq!(stepped(42_u8, 0..=100, Some(5), 1), 45);
453        assert_eq!(stepped(42_u8, 0..=100, Some(5), -1), 40);
454        assert_eq!(stepped(12_i32, 10..=20, Some(4), 1), 14);
455    }
456
457    #[test]
458    fn stepped_clamps_and_reaches_the_end() {
459        assert_eq!(stepped(98_u8, 0..=100, Some(5), 1), 100);
460        assert_eq!(stepped(100_u8, 0..=100, Some(5), 1), 100);
461        assert_eq!(stepped(0_u8, 0..=100, Some(5), -1), 0);
462        assert_eq!(stepped(9_i32, 0..=10, Some(4), 1), 10);
463        assert_eq!(stepped(5_i32, RangeInclusive::new(10, 0), None, -1), 4);
464    }
465
466    #[test]
467    fn stepped_ignores_invalid_steps() {
468        assert_eq!(stepped(5_i32, 0..=10, Some(0), 1), 6);
469        assert_eq!(stepped(5_i32, 0..=10, Some(-3), -1), 4);
470    }
471
472    #[test]
473    fn actions_apply_to_the_value() {
474        assert_eq!(Action::Increase.apply(40_u8, 0..=100, Some(10)), 50);
475        assert_eq!(Action::Decrease.apply(40_u8, 0..=100, Some(10)), 30);
476        assert_eq!(Action::Min.apply(40_u8, 20..=100, None), 20);
477        assert_eq!(
478            Action::Max.apply(40_u8, RangeInclusive::new(100, 20), None),
479            100
480        );
481    }
482
483    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
484        let chord: Chord = chord.parse().unwrap();
485        keymap.resolve(chord.key(), chord.modifiers())
486    }
487
488    #[test]
489    fn default_keymap_and_overrides() {
490        let keymap = default_keymap();
491        assert_eq!(press(&keymap, "ArrowRight"), Some(Action::Increase));
492        assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Increase));
493        assert_eq!(press(&keymap, "ArrowLeft"), Some(Action::Decrease));
494        assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Decrease));
495        assert_eq!(press(&keymap, "Home"), Some(Action::Min));
496        assert_eq!(press(&keymap, "End"), Some(Action::Max));
497
498        let custom = keymap
499            .unbind(&"ArrowUp".parse().unwrap())
500            .bind("=".parse().unwrap(), Action::Increase);
501        assert_eq!(press(&custom, "ArrowUp"), None);
502        assert_eq!(press(&custom, "="), Some(Action::Increase));
503        assert!(custom.clear().is_empty());
504    }
505
506    #[test]
507    fn show_value_formats_with_display() {
508        let s: Slider<'_, u8, ()> = slider(0..=100, 30).show_value();
509        let shown = s.format.as_ref().map(|format| format(s.value));
510        assert_eq!(shown.as_deref(), Some("30"));
511    }
512
513    #[test]
514    fn rail_fills_with_primary() {
515        for theme in [light(), dark()] {
516            let tokens = Tokens::of(&theme);
517            for status in STATES {
518                let colours = colours(&tokens, status, true);
519                assert_eq!(colours.filled, tokens.primary);
520                assert_ne!(colours.filled, colours.empty);
521            }
522        }
523    }
524
525    #[test]
526    fn interaction_changes_the_handle() {
527        for theme in [light(), dark()] {
528            let tokens = Tokens::of(&theme);
529            let active = style(&tokens, Status::Active, true);
530            let hovered = style(&tokens, Status::Hovered, true);
531            let dragged = style(&tokens, Status::Dragged, true);
532            assert_ne!(active.handle, hovered.handle);
533            assert_ne!(hovered.handle, dragged.handle);
534        }
535    }
536
537    #[test]
538    fn disabled_fades_and_ignores_interaction() {
539        for theme in [light(), dark()] {
540            let tokens = Tokens::of(&theme);
541            let active = colours(&tokens, Status::Active, true);
542            for status in STATES {
543                let disabled = colours(&tokens, status, false);
544                assert!((disabled.filled.a - active.filled.a * 0.5).abs() < 1e-6);
545                assert_eq!(disabled, colours(&tokens, Status::Active, false));
546            }
547        }
548    }
549}