1use 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
15const GAP: u16 = 2;
17
18const MIN_RAIL: u16 = 2;
20
21const MAX_ANIMATED_CELLS: u32 = 8;
23
24type ValueMessage<Msg> = Box<dyn Fn(f64) -> Msg>;
26
27pub 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 from_pointer: bool,
57 target: Option<f32>,
59}
60
61impl<Msg> Slider<Msg> {
62 #[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 #[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 #[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 #[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 #[must_use]
98 pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
99 self.suffix = suffix.into();
100 self
101 }
102
103 #[must_use]
105 pub fn disabled(mut self, disabled: bool) -> Self {
106 self.disabled = disabled;
107 self
108 }
109
110 #[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 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 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 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 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 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 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 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 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 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}