Skip to main content

zest_widget/widget/
slider.rs

1//! Horizontal value slider: a track with a filled portion and a draggable
2//! knob.
3//!
4//! Immediate-mode: the host owns the `f32` value and rebuilds the widget
5//! each frame, passing the current value to [`Slider::new`]. On both
6//! `TouchPhase::Down` and `TouchPhase::Moved` the value is derived from the
7//! touch x within the track, so no drag-origin state is kept. It is clamped
8//! to the range and emitted via [`on_change`](Slider::on_change).
9//!
10//! Colors come from the theme's accent [`Component`](zest_theme::Component):
11//! the filled portion uses `accent.base`, the unfilled track uses
12//! `background.divider`, and the knob uses `accent.on_base` with an
13//! `accent.border` outline.
14
15use super::Widget;
16use alloc::boxed::Box;
17use core::marker::PhantomData;
18use embedded_graphics::{pixelcolor::PixelColor, prelude::*, primitives::Rectangle};
19use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
20use zest_theme::Theme;
21
22/// Track thickness in pixels.
23const TRACK_THICKNESS: u32 = 6;
24/// Knob radius in pixels.
25const KNOB_RADIUS: u32 = 9;
26/// Default intrinsic width when sized `Shrink`.
27const INTRINSIC_W: u32 = 160;
28
29/// Horizontal value slider. Host owns the value; dragging emits the new
30/// clamped value through [`on_change`](Slider::on_change).
31pub struct Slider<'a, C: PixelColor, M: Clone> {
32    rect: Rectangle,
33    id: Option<WidgetId>,
34    value: f32,
35    min: f32,
36    max: f32,
37    step: Option<f32>,
38    on_change: Option<Box<dyn Fn(f32) -> M + 'a>>,
39    focused: bool,
40    pressed: bool,
41    width: Length,
42    height: Length,
43    _color: PhantomData<C>,
44}
45
46impl<'a, C: PixelColor, M: Clone> Slider<'a, C, M> {
47    /// New slider showing `value`. Default range is `0.0..=1.0`. Position
48    /// and size are assigned by the parent container via `arrange`.
49    pub fn new(value: f32) -> Self {
50        Self {
51            rect: Rectangle::zero(),
52            id: None,
53            value,
54            min: 0.0,
55            max: 1.0,
56            step: None,
57            on_change: None,
58            focused: false,
59            pressed: false,
60            width: Length::Fill,
61            height: Length::Fixed(2 * KNOB_RADIUS),
62            _color: PhantomData,
63        }
64    }
65
66    /// Inclusive value range. If `min >= max` the range collapses
67    /// and the slider reports `min`.
68    #[must_use]
69    pub fn range(mut self, min: f32, max: f32) -> Self {
70        self.min = min;
71        self.max = max;
72        self
73    }
74
75    /// Set a stable id so this slider can participate in focus traversal.
76    #[must_use]
77    pub fn id(mut self, id: WidgetId) -> Self {
78        self.id = Some(id);
79        self
80    }
81
82    /// Step size used for semantic increment/decrement actions.
83    #[must_use]
84    pub fn step(mut self, step: f32) -> Self {
85        self.step = Some(step.abs());
86        self
87    }
88
89    /// Callback invoked with the new clamped value whenever the
90    /// knob is pressed or dragged. Without it the slider is disabled and
91    /// ignores touches.
92    #[must_use]
93    pub fn on_change<F: Fn(f32) -> M + 'a>(mut self, f: F) -> Self {
94        self.on_change = Some(Box::new(f));
95        self
96    }
97
98    /// Width sizing intent.
99    #[must_use]
100    pub fn width(mut self, width: impl Into<Length>) -> Self {
101        self.width = width.into();
102        self
103    }
104
105    /// Height sizing intent.
106    #[must_use]
107    pub fn height(mut self, height: impl Into<Length>) -> Self {
108        self.height = height.into();
109        self
110    }
111
112    /// True iff a change callback is bound. Disabled sliders render dimmed
113    /// and ignore touches.
114    pub fn is_enabled(&self) -> bool {
115        self.on_change.is_some()
116    }
117
118    /// Fraction (0.0..=1.0) of the current value within the range.
119    fn fraction(&self) -> f32 {
120        if self.max <= self.min {
121            0.0
122        } else {
123            ((self.value - self.min) / (self.max - self.min)).clamp(0.0, 1.0)
124        }
125    }
126
127    /// Usable span for the knob center: leaves a `KNOB_RADIUS` margin at
128    /// each end so the knob stays inside the widget.
129    fn span(&self) -> (i32, i32) {
130        let left = self.rect.top_left.x + KNOB_RADIUS as i32;
131        let right = self.rect.top_left.x + self.rect.size.width as i32 - KNOB_RADIUS as i32;
132        (left, right.max(left))
133    }
134
135    fn hit_test(&self, point: Point) -> bool {
136        let tl = self.rect.top_left;
137        let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
138        point.x >= tl.x && point.x < br.x && point.y >= tl.y && point.y < br.y
139    }
140
141    /// Convert a touch x into a clamped value within the range.
142    fn value_at(&self, x: i32) -> f32 {
143        let (left, right) = self.span();
144        let frac = if right <= left {
145            0.0
146        } else {
147            ((x - left) as f32 / (right - left) as f32).clamp(0.0, 1.0)
148        };
149        let v = self.min + frac * (self.max - self.min);
150        v.clamp(self.min.min(self.max), self.min.max(self.max))
151    }
152
153    fn action_step(&self) -> f32 {
154        if let Some(step) = self.step
155            && step > 0.0
156        {
157            return step;
158        }
159
160        let range = (self.max - self.min).abs();
161        if range <= f32::EPSILON {
162            0.0
163        } else {
164            (range / 20.0).max(f32::EPSILON)
165        }
166    }
167
168    fn adjusted_value(&self, delta: f32) -> f32 {
169        (self.value + delta).clamp(self.min.min(self.max), self.min.max(self.max))
170    }
171}
172
173impl<'a, C: PixelColor, M: Clone> Widget<C, M> for Slider<'a, C, M> {
174    fn measure(&mut self, constraints: Constraints) -> Size {
175        let w = self.width.resolve(INTRINSIC_W, constraints.max.width);
176        let h = self.height.resolve(2 * KNOB_RADIUS, constraints.max.height);
177        constraints.clamp(Size::new(w, h))
178    }
179
180    fn preferred_size(&self) -> (Length, Length) {
181        (self.width, self.height)
182    }
183
184    fn arrange(&mut self, rect: Rectangle) {
185        self.rect = rect;
186    }
187
188    fn rect(&self) -> Rectangle {
189        self.rect
190    }
191
192    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
193        let Some(cb) = self.on_change.as_ref() else {
194            return None;
195        };
196        match phase {
197            // Drive value directly from x on press and during drag.
198            TouchPhase::Down => {
199                if self.hit_test(point) {
200                    self.pressed = true;
201                    Some(cb(self.value_at(point.x)))
202                } else {
203                    self.pressed = false;
204                    None
205                }
206            }
207            TouchPhase::Moved => {
208                // Hit-test Moved too: there's no per-widget drag ownership,
209                // so without it a drag could be consumed by the wrong slider.
210                if self.hit_test(point) {
211                    self.pressed = true;
212                    Some(cb(self.value_at(point.x)))
213                } else {
214                    self.pressed = false;
215                    None
216                }
217            }
218            TouchPhase::Up => {
219                self.pressed = false;
220                None
221            }
222        }
223    }
224
225    fn mark_pressed(&mut self, point: Point) {
226        if self.is_enabled() && self.hit_test(point) {
227            self.pressed = true;
228        }
229    }
230
231    fn widget_id(&self) -> Option<WidgetId> {
232        self.id
233    }
234
235    fn is_focusable(&self) -> bool {
236        self.id.is_some() && self.is_enabled()
237    }
238
239    fn handle_action(&mut self, action: UiAction) -> Option<M> {
240        if !self.is_enabled() {
241            return None;
242        }
243
244        let step = self.action_step();
245        match action {
246            UiAction::Increment | UiAction::NavigateRight | UiAction::NavigateUp if step > 0.0 => {
247                self.on_change
248                    .as_ref()
249                    .map(|cb| cb(self.adjusted_value(step)))
250            }
251            UiAction::Decrement | UiAction::NavigateLeft | UiAction::NavigateDown if step > 0.0 => {
252                self.on_change
253                    .as_ref()
254                    .map(|cb| cb(self.adjusted_value(-step)))
255            }
256            _ => None,
257        }
258    }
259
260    fn sync_focus(&mut self, focused: Option<WidgetId>) {
261        self.focused = self.id.is_some() && self.id == focused;
262    }
263
264    fn focus_at(&self, point: Point) -> Option<WidgetId> {
265        if self.is_focusable() && self.hit_test(point) {
266            self.id
267        } else {
268            None
269        }
270    }
271
272    fn draw<'t>(
273        &self,
274        renderer: &mut dyn Renderer<C>,
275        theme: &Theme<'t, C>,
276    ) -> Result<(), RenderError> {
277        let accent = &theme.accent;
278        let (left, right) = self.span();
279        let cy = self.rect.top_left.y + self.rect.size.height as i32 / 2;
280        let track_top = cy - TRACK_THICKNESS as i32 / 2;
281
282        let track_color = theme.background.divider;
283        let fill_color = if !self.is_enabled() {
284            theme.background.divider
285        } else if self.pressed {
286            accent.pressed
287        } else {
288            accent.base
289        };
290
291        // Full track.
292        let track = Rectangle::new(
293            Point::new(left, track_top),
294            Size::new((right - left).max(0) as u32, TRACK_THICKNESS),
295        );
296        renderer.fill_rect(track, track_color)?;
297
298        // Knob center x from the current fraction.
299        let knob_x = left + ((right - left) as f32 * self.fraction()) as i32;
300
301        // Filled portion from the left up to the knob.
302        let filled = Rectangle::new(
303            Point::new(left, track_top),
304            Size::new((knob_x - left).max(0) as u32, TRACK_THICKNESS),
305        );
306        renderer.fill_rect(filled, fill_color)?;
307
308        // Knob: a border ring (full radius) with the knob fill punched on
309        // top, leaving a 2px accent outline — the renderer has no
310        // stroke_circle primitive.
311        let knob_color = if self.is_enabled() {
312            accent.on_base
313        } else {
314            theme.background.base
315        };
316        let center = Point::new(knob_x, cy);
317        let knob_border = if self.focused {
318            accent.base
319        } else {
320            accent.border
321        };
322        renderer.fill_circle(center, KNOB_RADIUS, knob_border)?;
323        renderer.fill_circle(center, KNOB_RADIUS.saturating_sub(2), knob_color)?;
324
325        Ok(())
326    }
327}