Skip to main content

qframe/widgets/
slider.rs

1//! Sliders.
2
3use std::time::Duration;
4
5use super::numeric::{Formatter, Steps};
6use crate::event::{Event, MouseButton, MouseKind};
7use crate::geometry::{Rect, Size};
8use crate::keymap::Key;
9use crate::motion::Easing;
10use crate::style::CellStyle;
11use crate::text;
12use crate::theme::State;
13use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
14
15/// Cells between the value and the rail.
16const GAP: u16 = 2;
17
18/// The fewest rail cells worth drawing.
19const MIN_RAIL: u16 = 2;
20
21/// The most cells a keyboard jump animates through; longer jumps move several cells a frame.
22const MAX_ANIMATED_CELLS: u32 = 8;
23
24/// Builds a message from a new value.
25type ValueMessage<Msg> = Box<dyn Fn(f64) -> Msg>;
26
27/// A value chosen along a range by moving a knob on a rail.
28///
29/// The value is written before the rail in the accent colour. The done part of the rail is
30/// drawn in the accent, the knob `◆` sits at the value and breathes while focused, and the rest
31/// of the rail is a quiet raised tone. In ASCII mode the rail is drawn with cell colours instead
32/// of glyphs. The widget takes all the width it is given.
33///
34/// Left and Right move one step, Page Up and Page Down a tenth of the range, Home and End jump
35/// to the ends. Pressing the rail jumps the knob there and dragging moves it. The mouse wheel over
36/// the slider moves one step, up to increase and down to decrease; the slider keeps the wheel, so
37/// a scroll view around it does not scroll while the pointer rests on it, and a disabled slider
38/// lets the wheel pass. Keyboard jumps step the knob cell by cell over `motion.step` per cell.
39/// The application owns the value.
40///
41/// Style keys: `slider` (`fill`, `fill-cell`, `track`, `knob`) and `slider-value` (`fg`,
42/// `bold`) with states `hover`, `focus`, `disabled`.
43pub struct Slider<Msg> {
44    value: f64,
45    steps: Steps,
46    format: Option<Formatter>,
47    suffix: String,
48    disabled: bool,
49    on_change: Option<ValueMessage<Msg>>,
50}
51
52#[derive(Debug, Default)]
53struct SliderMemory {
54    dragging: bool,
55    /// Whether the last change came from the pointer, which the knob follows at once.
56    from_pointer: bool,
57    /// The fraction the knob moved to last, to size the next animation.
58    target: Option<f32>,
59}
60
61impl<Msg> Slider<Msg> {
62    /// A slider from 0 to 100 in steps of 1 showing `value`.
63    #[must_use]
64    pub fn new(value: f64) -> Self {
65        Self {
66            value,
67            steps: Steps::new(0.0, 100.0, 1.0),
68            format: None,
69            suffix: String::new(),
70            disabled: false,
71            on_change: None,
72        }
73    }
74
75    /// The smallest and largest value.
76    #[must_use]
77    pub fn range(mut self, min: f64, max: f64) -> Self {
78        self.steps = Steps::new(min, max, self.steps.step);
79        self
80    }
81
82    /// The distance of one step; values snap to steps from the minimum.
83    #[must_use]
84    pub fn step(mut self, step: f64) -> Self {
85        self.steps = Steps::new(self.steps.min, self.steps.max, step);
86        self
87    }
88
89    /// Writes the value with `format` instead of the step's decimals.
90    #[must_use]
91    pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self {
92        self.format = Some(Box::new(format));
93        self
94    }
95
96    /// Text written right after the value, such as `"%"` or `" ms"`.
97    #[must_use]
98    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
99        self.suffix = suffix.into();
100        self
101    }
102
103    /// Greys the slider out; it cannot be focused or moved.
104    #[must_use]
105    pub fn disabled(mut self, disabled: bool) -> Self {
106        self.disabled = disabled;
107        self
108    }
109
110    /// Message carrying the new value whenever the knob moves to another step.
111    #[must_use]
112    pub fn on_change(mut self, message: impl Fn(f64) -> Msg + 'static) -> Self {
113        self.on_change = Some(Box::new(message));
114        self
115    }
116
117    fn active(&self) -> bool {
118        !self.disabled && self.on_change.is_some()
119    }
120
121    fn label(&self, value: f64) -> String {
122        let written = self.format.as_ref().map_or_else(|| self.steps.write(value), |format| format(value));
123        format!("{written}{}", self.suffix)
124    }
125
126    /// Width reserved for the value, so the rail does not move while the value changes.
127    fn label_width(&self) -> u16 {
128        [self.steps.min, self.steps.max, self.value].iter().map(|v| text::width(&self.label(*v))).max().unwrap_or(0)
129    }
130
131    /// The rail inside `area`, or nothing when there is no room for one.
132    fn rail(&self, area: Rect) -> Option<Rect> {
133        let left = self.label_width().saturating_add(GAP);
134        let width = area.width.saturating_sub(left);
135        (width >= MIN_RAIL).then(|| Rect::new(area.x + i32::from(left), area.y, width, 1))
136    }
137
138    /// Moves to `value` when it is another step.
139    fn change(&self, cx: &mut EventCx<'_, Msg>, value: f64, from_pointer: bool) {
140        cx.memory::<SliderMemory>().from_pointer = from_pointer;
141        let value = self.steps.snap(value);
142        if value != self.steps.snap(self.value)
143            && let Some(message) = &self.on_change
144        {
145            cx.emit(message(value));
146        }
147    }
148
149    fn change_at(&self, cx: &mut EventCx<'_, Msg>, x: i32) {
150        let Some(rail) = self.rail(cx.area()) else {
151            return;
152        };
153        let cell = (x - rail.x).clamp(0, i32::from(rail.width) - 1);
154        let fraction = f64::from(cell) / f64::from(rail.width.saturating_sub(1).max(1));
155        self.change(cx, self.steps.at(fraction), true);
156    }
157}
158
159impl<Msg: 'static> Widget<Msg> for Slider<Msg> {
160    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
161        Size::new(available.width, 1.min(available.height))
162    }
163
164    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
165        let mut states = if self.active() { cx.pressable_states() } else { Vec::new() };
166        if self.disabled {
167            states.push(State::Disabled);
168        }
169        if self.active() {
170            cx.register_hit(area);
171        }
172        let value = self.steps.clamp(self.value);
173        let label_style = cx.style("slider-value", None, &states).text();
174        let label = self.label(value);
175        let label_width = self.label_width();
176        let label_x = area.x + i32::from(label_width.saturating_sub(text::width(&label)));
177        cx.text(label_x, area.y, &label, label_style, area.width);
178        let Some(rail) = self.rail(area) else {
179            return;
180        };
181
182        let style = cx.style("slider", None, &states);
183        let fill = style.color("fill").unwrap_or_else(|| cx.color("accent"));
184        let fill_cell = style.color("fill-cell").unwrap_or_else(|| cx.color("active"));
185        let track = style.color("track").unwrap_or_else(|| cx.color("raised"));
186        let knob = style.color("knob").unwrap_or_else(|| cx.color("accent"));
187
188        let travel = rail.width.saturating_sub(1);
189        // Precision beyond f32 is not visible in a terminal cell.
190        let fraction = self.steps.fraction(value) as f32;
191        let (from_pointer, previous) = {
192            let memory = cx.memory::<SliderMemory>();
193            (memory.from_pointer, memory.target.replace(fraction))
194        };
195        let duration = match previous {
196            Some(previous) if !from_pointer => {
197                let cells = ((fraction - previous).abs() * f32::from(travel)).round() as u32;
198                cx.env().theme().motion().step * cells.min(MAX_ANIMATED_CELLS)
199            }
200            _ => Duration::ZERO,
201        };
202        let shown = cx.animate("knob", fraction, duration, Easing::Linear);
203        let position = crate::motion::steps(shown, travel);
204
205        let rail_glyph = cx.env().icons().glyph("slider-rail").into_owned();
206        let knob_glyph = cx.env().icons().glyph("slider-knob").into_owned();
207        // A blank glyph (ASCII mode) shows the rail as cell colours instead.
208        let cells = rail_glyph.trim().is_empty() || knob_glyph.trim().is_empty();
209        for cell in 0..rail.width {
210            let x = rail.x + i32::from(cell);
211            let (glyph, color, block) = match cell.cmp(&position) {
212                std::cmp::Ordering::Less => (&rail_glyph, fill, fill_cell),
213                std::cmp::Ordering::Equal => (&knob_glyph, knob, knob),
214                std::cmp::Ordering::Greater => (&rail_glyph, track, track),
215            };
216            if cells {
217                cx.clear(Rect::new(x, area.y, 1, 1), block);
218            } else {
219                cx.text(x, area.y, glyph, CellStyle::fg(color), 1);
220            }
221        }
222    }
223
224    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
225        if !self.active() {
226            return false;
227        }
228        let value = self.steps.snap(self.value);
229        match event {
230            Event::Key(key) => {
231                let target = if key.is_plain(Key::Left) {
232                    self.steps.nudge(value, -1.0)
233                } else if key.is_plain(Key::Right) {
234                    self.steps.nudge(value, 1.0)
235                } else if key.is_plain(Key::PageDown) {
236                    self.steps.nudge(value, -self.steps.large())
237                } else if key.is_plain(Key::PageUp) {
238                    self.steps.nudge(value, self.steps.large())
239                } else if key.is_plain(Key::Home) {
240                    self.steps.min
241                } else if key.is_plain(Key::End) {
242                    self.steps.max
243                } else {
244                    return false;
245                };
246                self.change(cx, target, false);
247                true
248            }
249            Event::Mouse(mouse) => match mouse.kind {
250                MouseKind::Down(MouseButton::Left) => {
251                    // Pressing the value only focuses; the rail moves the knob.
252                    if self.rail(cx.area()).is_some_and(|rail| mouse.x >= rail.x) {
253                        cx.capture_pointer();
254                        cx.memory::<SliderMemory>().dragging = true;
255                        self.change_at(cx, mouse.x);
256                    }
257                    true
258                }
259                MouseKind::Drag(MouseButton::Left) if cx.memory::<SliderMemory>().dragging => {
260                    self.change_at(cx, mouse.x);
261                    true
262                }
263                MouseKind::Up(MouseButton::Left) => {
264                    cx.memory::<SliderMemory>().dragging = false;
265                    true
266                }
267                MouseKind::ScrollUp | MouseKind::ScrollDown => {
268                    let direction = if mouse.kind == MouseKind::ScrollUp { 1.0 } else { -1.0 };
269                    self.change(cx, self.steps.nudge(value, direction), true);
270                    true
271                }
272                _ => false,
273            },
274            _ => false,
275        }
276    }
277
278    fn focusable(&self) -> bool {
279        self.active()
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::icons::GlyphMode;
287    use crate::runtime::{App, Command, Harness};
288    use crate::widget::View;
289
290    struct Demo {
291        value: f64,
292        disabled: bool,
293    }
294
295    impl App for Demo {
296        type Msg = f64;
297        fn update(&mut self, value: f64) -> Command<f64> {
298            self.value = value;
299            Command::none()
300        }
301        fn view(&self, ui: &mut View<'_, f64>) {
302            ui.add(Slider::new(self.value).suffix("%").disabled(self.disabled).on_change(|v| v)).id("volume");
303        }
304    }
305
306    fn harness(value: f64) -> Harness<Demo> {
307        let mut h = Harness::new(Demo { value, disabled: false }, 17, 1);
308        h.set_reduced_motion(true);
309        h
310    }
311
312    #[test]
313    fn value_before_a_rail_with_the_knob_at_the_value() {
314        let h = harness(50.0);
315        // Four cells for "100%", two of gap, eleven of rail; 50% is the middle cell.
316        assert_eq!(h.screen(), " 50%  ━━━━━◆━━━━━\n");
317        let theme = h.env().theme();
318        assert_eq!(h.fg(1, 0), theme.color("accent"));
319        assert!(h.is_bold(1, 0));
320        assert_eq!(h.fg(6, 0), theme.color("accent"));
321        assert_eq!(h.fg(16, 0), theme.color("raised"));
322    }
323
324    #[test]
325    fn keys_step_jump_and_stop_at_the_ends() {
326        let mut h = harness(50.0);
327        h.press("tab").press("right");
328        assert_eq!(h.app().value, 51.0);
329        h.press("pgup");
330        assert_eq!(h.app().value, 61.0);
331        h.press("end").press("right");
332        assert_eq!(h.app().value, 100.0);
333        assert_eq!(h.screen(), "100%  ━━━━━━━━━━◆\n");
334        h.press("home").press("pgdn");
335        assert_eq!(h.app().value, 0.0);
336    }
337
338    #[test]
339    fn pressing_the_rail_jumps_and_dragging_follows() {
340        let mut h = harness(0.0);
341        h.click(16, 0);
342        assert_eq!(h.app().value, 100.0);
343        h.click(1, 0);
344        assert_eq!(h.app().value, 100.0, "pressing the value only focuses");
345        h.mouse(MouseKind::Down(MouseButton::Left), 11, 0);
346        assert_eq!(h.app().value, 50.0);
347        // Dragging past the rail keeps following, clamped to the end.
348        h.mouse(MouseKind::Drag(MouseButton::Left), 0, 0);
349        assert_eq!(h.app().value, 0.0);
350        h.mouse(MouseKind::Up(MouseButton::Left), 0, 0);
351        h.mouse(MouseKind::Drag(MouseButton::Left), 16, 0);
352        assert_eq!(h.app().value, 0.0, "no drag after release");
353    }
354
355    #[test]
356    fn keyboard_jumps_step_the_knob_cell_by_cell() {
357        let mut h = Harness::new(Demo { value: 0.0, disabled: false }, 17, 1);
358        h.press("tab").press("end");
359        let step = h.env().theme().motion().step;
360        assert_eq!(h.screen(), "100%  ◆━━━━━━━━━━\n", "the value is new, the knob has not left yet");
361        h.advance(step * 4);
362        assert_eq!(h.screen(), "100%  ━━━━━◆━━━━━\n", "halfway after half of the eight steps");
363        h.advance(step * 4);
364        assert_eq!(h.screen(), "100%  ━━━━━━━━━━◆\n");
365    }
366
367    #[test]
368    fn ascii_draws_the_rail_with_colours_and_disabled_ignores_input() {
369        let mut h = harness(50.0);
370        h.set_glyph_mode(GlyphMode::Ascii);
371        assert_eq!(h.screen(), " 50%\n");
372        let theme = h.env().theme();
373        assert_eq!(h.bg(11, 0), theme.color("accent"));
374        assert_ne!(h.bg(6, 0), h.bg(16, 0));
375        let mut h = Harness::new(Demo { value: 50.0, disabled: true }, 17, 1);
376        h.press("tab").press("right").click(16, 0);
377        assert_eq!(h.app().value, 50.0);
378        assert_eq!(h.fg(1, 0), theme.color("muted"));
379    }
380
381    /// A slider inside a scroll view taller than the screen, as on a settings page.
382    struct Scrolled {
383        value: f64,
384        disabled: bool,
385    }
386
387    impl App for Scrolled {
388        type Msg = f64;
389        fn update(&mut self, value: f64) -> Command<f64> {
390            self.value = value;
391            Command::none()
392        }
393        fn view(&self, ui: &mut View<'_, f64>) {
394            ui.add_with(crate::widgets::ScrollView::new(), |ui| {
395                ui.add(Slider::new(self.value).disabled(self.disabled).on_change(|v| v)).id("volume");
396                for row in 0..10 {
397                    ui.add(crate::widgets::Text::new(format!("row {row}")));
398                }
399            })
400            .fill();
401        }
402    }
403
404    #[test]
405    fn the_wheel_over_the_slider_moves_one_step_and_keeps_the_page_still() {
406        let mut h = Harness::new(Scrolled { value: 50.0, disabled: false }, 17, 4);
407        h.set_reduced_motion(true);
408        h.mouse(MouseKind::ScrollUp, 10, 0);
409        assert_eq!(h.app().value, 51.0);
410        h.mouse(MouseKind::ScrollDown, 1, 0).mouse(MouseKind::ScrollDown, 1, 0);
411        assert_eq!(h.app().value, 49.0, "the value part takes the wheel too");
412        assert!(h.screen().starts_with(" 49  "), "the page did not scroll: {}", h.screen());
413        assert!(!h.is_focused("volume"), "the wheel does not take focus");
414        h.mouse(MouseKind::ScrollDown, 5, 2);
415        assert!(!h.screen().contains(" 49 "), "away from the slider the page scrolls: {}", h.screen());
416    }
417
418    #[test]
419    fn the_wheel_stops_at_the_ends_and_follows_the_step() {
420        struct Stepped(f64);
421        impl App for Stepped {
422            type Msg = f64;
423            fn update(&mut self, value: f64) -> Command<f64> {
424                self.0 = value;
425                Command::none()
426            }
427            fn view(&self, ui: &mut View<'_, f64>) {
428                ui.add(Slider::new(self.0).range(0.0, 10.0).step(2.5).on_change(|v| v));
429            }
430        }
431        let mut h = Harness::new(Stepped(7.5), 20, 1);
432        h.mouse(MouseKind::ScrollUp, 12, 0);
433        assert_eq!(h.app().0, 10.0);
434        h.mouse(MouseKind::ScrollUp, 12, 0);
435        assert_eq!(h.app().0, 10.0, "no message past the end");
436        h.mouse(MouseKind::ScrollDown, 12, 0);
437        assert_eq!(h.app().0, 7.5);
438        h.advance(Duration::from_millis(1));
439        assert_eq!(h.screen(), " 7.5  ━━━━━━━━━━◆━━━\n", "the knob follows the wheel at once");
440    }
441
442    #[test]
443    fn a_disabled_slider_lets_the_wheel_scroll_the_page() {
444        let mut h = Harness::new(Scrolled { value: 50.0, disabled: true }, 17, 4);
445        h.mouse(MouseKind::ScrollUp, 10, 0).mouse(MouseKind::ScrollDown, 10, 0);
446        assert_eq!(h.app().value, 50.0);
447        h.mouse(MouseKind::ScrollDown, 10, 0);
448        assert!(!h.screen().contains(" 50 "), "the wheel scrolled the page instead: {}", h.screen());
449    }
450
451    #[test]
452    fn an_open_or_broken_range_draws_without_panicking() {
453        struct Open(f64, f64);
454        impl App for Open {
455            type Msg = f64;
456            fn update(&mut self, _: f64) -> Command<f64> {
457                Command::none()
458            }
459            fn view(&self, ui: &mut View<'_, f64>) {
460                ui.add(Slider::new(3.0).range(self.0, self.1).on_change(|v| v));
461            }
462        }
463        for (min, max) in [(f64::NEG_INFINITY, 10.0), (f64::NAN, 10.0), (0.0, f64::NAN)] {
464            let mut h = Harness::new(Open(min, max), 20, 1);
465            h.press("tab").press("right").press("end").advance(Duration::from_millis(500));
466            assert!(h.screen().contains('3'), "{min} to {max}: {}", h.screen());
467        }
468    }
469
470    #[test]
471    fn narrow_space_keeps_only_the_value() {
472        let h = Harness::new(Demo { value: 7.0, disabled: false }, 6, 1);
473        assert_eq!(h.screen(), "  7%\n");
474    }
475}