Skip to main content

zest_widget/widget/
spin_button.rs

1//! Numeric stepper with `-` / `+` buttons flanking a value label.
2//!
3//! Stateless w.r.t. the value: the host owns the `i32` and rebuilds the
4//! widget each frame. The `on_change` callback receives the new value
5//! (clamped to `min..=max`) when a side is tapped.
6
7use super::Widget;
8use alloc::{boxed::Box, format, string::String};
9use core::marker::PhantomData;
10use embedded_graphics::{
11    pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
12};
13use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
14use zest_theme::{ButtonCatalog, ButtonClass, Status, Theme};
15
16const H_BUTTON_W: u32 = 28;
17const V_BUTTON_H: u32 = 24;
18
19/// Layout direction for a [`SpinButton`].
20#[derive(Copy, Clone, Debug, PartialEq, Eq)]
21pub enum SpinOrientation {
22    /// `[-] [value] [+]`
23    Horizontal,
24    /// `[+]` / `[value]` / `[-]` (plus on top).
25    Vertical,
26}
27
28#[derive(Copy, Clone, Debug, PartialEq, Eq)]
29enum Side {
30    Plus,
31    Minus,
32}
33
34/// Numeric stepper widget.
35pub struct SpinButton<'a, C: PixelColor, M: Clone> {
36    rect: Rectangle,
37    id: Option<WidgetId>,
38    value: i32,
39    min: i32,
40    max: i32,
41    step: i32,
42    orientation: SpinOrientation,
43    display: Option<String>,
44    on_change: Option<Box<dyn Fn(i32) -> M + 'a>>,
45    width: Length,
46    height: Length,
47    focused: Option<Side>,
48    pressed: Option<Side>,
49    _color: PhantomData<C>,
50}
51
52impl<'a, C: PixelColor, M: Clone> SpinButton<'a, C, M> {
53    /// New spinner showing `value`. Defaults: range `0..=99`, step 1,
54    /// horizontal.
55    pub fn new(value: i32) -> Self {
56        Self {
57            rect: Rectangle::zero(),
58            id: None,
59            value,
60            min: 0,
61            max: 99,
62            step: 1,
63            orientation: SpinOrientation::Horizontal,
64            display: None,
65            on_change: None,
66            width: Length::Fill,
67            height: Length::Fill,
68            focused: None,
69            pressed: None,
70            _color: PhantomData,
71        }
72    }
73
74    /// Minimum value (inclusive).
75    #[must_use]
76    pub fn min(mut self, min: i32) -> Self {
77        self.min = min;
78        self
79    }
80
81    /// Maximum value (inclusive).
82    #[must_use]
83    pub fn max(mut self, max: i32) -> Self {
84        self.max = max;
85        self
86    }
87
88    /// Step applied on each `+`/`-` tap.
89    #[must_use]
90    pub fn step(mut self, step: i32) -> Self {
91        self.step = step;
92        self
93    }
94
95    /// Layout orientation.
96    #[must_use]
97    pub fn orientation(mut self, o: SpinOrientation) -> Self {
98        self.orientation = o;
99        self
100    }
101
102    /// Override the displayed string. Default is `format!("{value}")`.
103    #[must_use]
104    pub fn display(mut self, s: impl Into<String>) -> Self {
105        self.display = Some(s.into());
106        self
107    }
108
109    /// Set a stable base id so both sides can participate in focus traversal.
110    #[must_use]
111    pub fn id(mut self, id: WidgetId) -> Self {
112        self.id = Some(id);
113        self
114    }
115
116    /// Callback fired with the clamped new value on each tap.
117    #[must_use]
118    pub fn on_change<F: Fn(i32) -> M + 'a>(mut self, f: F) -> Self {
119        self.on_change = Some(Box::new(f));
120        self
121    }
122
123    /// Width sizing intent.
124    #[must_use]
125    pub fn width(mut self, w: impl Into<Length>) -> Self {
126        self.width = w.into();
127        self
128    }
129
130    /// Height sizing intent.
131    #[must_use]
132    pub fn height(mut self, h: impl Into<Length>) -> Self {
133        self.height = h.into();
134        self
135    }
136
137    fn intrinsic(&self) -> Size {
138        match self.orientation {
139            SpinOrientation::Horizontal => Size::new(90, 24),
140            SpinOrientation::Vertical => Size::new(28, 80),
141        }
142    }
143
144    fn minus_rect(&self) -> Rectangle {
145        let r = self.rect;
146        match self.orientation {
147            SpinOrientation::Horizontal => {
148                Rectangle::new(r.top_left, Size::new(H_BUTTON_W, r.size.height))
149            }
150            SpinOrientation::Vertical => Rectangle::new(
151                r.top_left + Point::new(0, r.size.height.saturating_sub(V_BUTTON_H) as i32),
152                Size::new(r.size.width, V_BUTTON_H),
153            ),
154        }
155    }
156
157    fn plus_rect(&self) -> Rectangle {
158        let r = self.rect;
159        match self.orientation {
160            SpinOrientation::Horizontal => Rectangle::new(
161                r.top_left + Point::new(r.size.width.saturating_sub(H_BUTTON_W) as i32, 0),
162                Size::new(H_BUTTON_W, r.size.height),
163            ),
164            SpinOrientation::Vertical => {
165                Rectangle::new(r.top_left, Size::new(r.size.width, V_BUTTON_H))
166            }
167        }
168    }
169
170    fn value_rect(&self) -> Rectangle {
171        let r = self.rect;
172        match self.orientation {
173            SpinOrientation::Horizontal => {
174                let w = r.size.width.saturating_sub(H_BUTTON_W * 2);
175                Rectangle::new(
176                    r.top_left + Point::new(H_BUTTON_W as i32, 0),
177                    Size::new(w, r.size.height),
178                )
179            }
180            SpinOrientation::Vertical => {
181                let h = r.size.height.saturating_sub(V_BUTTON_H * 2);
182                Rectangle::new(
183                    r.top_left + Point::new(0, V_BUTTON_H as i32),
184                    Size::new(r.size.width, h),
185                )
186            }
187        }
188    }
189
190    fn hit_test(&self, point: Point) -> Option<Side> {
191        if rect_contains(self.minus_rect(), point) {
192            Some(Side::Minus)
193        } else if rect_contains(self.plus_rect(), point) {
194            Some(Side::Plus)
195        } else {
196            None
197        }
198    }
199
200    fn side_enabled(&self, side: Side) -> bool {
201        if self.on_change.is_none() {
202            return false;
203        }
204        match side {
205            Side::Minus => self.value > self.min,
206            Side::Plus => self.value < self.max,
207        }
208    }
209
210    fn side_status(&self, side: Side) -> Status {
211        if !self.side_enabled(side) {
212            Status::Disabled
213        } else if self.pressed == Some(side) {
214            Status::Pressed
215        } else if self.focused == Some(side) {
216            Status::Focused
217        } else {
218            Status::Active
219        }
220    }
221
222    fn apply(&self, side: Side) -> i32 {
223        let next = match side {
224            Side::Minus => self.value.saturating_sub(self.step),
225            Side::Plus => self.value.saturating_add(self.step),
226        };
227        next.clamp(self.min, self.max)
228    }
229
230    fn side_id(&self, side: Side) -> Option<WidgetId> {
231        self.id.map(|base| {
232            let offset = match side {
233                Side::Minus => 1,
234                Side::Plus => 2,
235            };
236            WidgetId::new(base.raw().wrapping_add(offset))
237        })
238    }
239
240    fn focused_side(&self, target: WidgetId) -> Option<Side> {
241        [Side::Minus, Side::Plus]
242            .into_iter()
243            .find(|side| self.side_id(*side) == Some(target))
244    }
245
246    fn ordered_sides(&self) -> [Side; 2] {
247        match self.orientation {
248            SpinOrientation::Horizontal => [Side::Minus, Side::Plus],
249            SpinOrientation::Vertical => [Side::Plus, Side::Minus],
250        }
251    }
252
253    fn emit_change(&self, side: Side) -> Option<M> {
254        if !self.side_enabled(side) {
255            return None;
256        }
257
258        self.on_change.as_ref().map(|cb| cb(self.apply(side)))
259    }
260}
261
262fn rect_contains(rect: Rectangle, p: Point) -> bool {
263    let tl = rect.top_left;
264    let br = tl + Point::new(rect.size.width as i32, rect.size.height as i32);
265    p.x >= tl.x && p.x < br.x && p.y >= tl.y && p.y < br.y
266}
267
268/// Horizontal spinner `[-] [value] [+]`.
269pub fn horizontal_spin_button<'a, C: PixelColor, M: Clone>(value: i32) -> SpinButton<'a, C, M> {
270    SpinButton::new(value).orientation(SpinOrientation::Horizontal)
271}
272
273/// Vertical spinner stacked plus-on-top / value / minus-on-bottom.
274pub fn vertical_spin_button<'a, C: PixelColor, M: Clone>(value: i32) -> SpinButton<'a, C, M> {
275    SpinButton::new(value).orientation(SpinOrientation::Vertical)
276}
277
278impl<'a, C: PixelColor, M: Clone> Widget<C, M> for SpinButton<'a, C, M> {
279    fn measure(&mut self, constraints: Constraints) -> Size {
280        let intrinsic = self.intrinsic();
281        let w = self.width.resolve(intrinsic.width, constraints.max.width);
282        let h = self
283            .height
284            .resolve(intrinsic.height, constraints.max.height);
285        constraints.clamp(Size::new(w, h))
286    }
287
288    fn preferred_size(&self) -> (Length, Length) {
289        (self.width, self.height)
290    }
291
292    fn arrange(&mut self, rect: Rectangle) {
293        self.rect = rect;
294    }
295
296    fn rect(&self) -> Rectangle {
297        self.rect
298    }
299
300    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
301        if self.on_change.is_none() {
302            return None;
303        }
304        match phase {
305            TouchPhase::Down => {
306                let hit = self.hit_test(point);
307                self.pressed = hit.filter(|s| self.side_enabled(*s));
308                None
309            }
310            TouchPhase::Moved => {
311                if self.pressed.is_some() && self.hit_test(point) != self.pressed {
312                    self.pressed = None;
313                }
314                None
315            }
316            TouchPhase::Up => {
317                let now = self.hit_test(point);
318                let was = self.pressed.take();
319                if let (Some(n), Some(w)) = (now, was) {
320                    if n == w {
321                        if let Some(cb) = self.on_change.as_ref() {
322                            return Some(cb(self.apply(n)));
323                        }
324                    }
325                }
326                None
327            }
328        }
329    }
330
331    fn mark_pressed(&mut self, point: Point) {
332        if self.pressed.is_none() && self.on_change.is_some() {
333            if let Some(side) = self.hit_test(point) {
334                if self.side_enabled(side) {
335                    self.pressed = Some(side);
336                }
337            }
338        }
339    }
340
341    fn collect_focusable(&self, out: &mut alloc::vec::Vec<WidgetId>) {
342        for side in self.ordered_sides() {
343            if self.side_enabled(side)
344                && let Some(id) = self.side_id(side)
345            {
346                out.push(id);
347            }
348        }
349    }
350
351    fn sync_focus(&mut self, focused: Option<WidgetId>) {
352        self.focused = focused.and_then(|target| self.focused_side(target));
353    }
354
355    fn route_action(&mut self, target: WidgetId, action: UiAction) -> Option<M> {
356        let focused_side = self.focused_side(target)?;
357        match action {
358            UiAction::Activate => self.emit_change(focused_side),
359            UiAction::Increment | UiAction::NavigateRight | UiAction::NavigateUp => {
360                self.emit_change(Side::Plus)
361            }
362            UiAction::Decrement | UiAction::NavigateLeft | UiAction::NavigateDown => {
363                self.emit_change(Side::Minus)
364            }
365            _ => None,
366        }
367    }
368
369    fn focus_rect(&self, target: WidgetId) -> Option<Rectangle> {
370        let side = self.focused_side(target)?;
371        Some(match side {
372            Side::Minus => self.minus_rect(),
373            Side::Plus => self.plus_rect(),
374        })
375    }
376
377    fn focus_at(&self, point: Point) -> Option<WidgetId> {
378        self.hit_test(point).and_then(|side| self.side_id(side))
379    }
380
381    fn draw<'t>(
382        &self,
383        renderer: &mut dyn Renderer<C>,
384        theme: &Theme<'t, C>,
385    ) -> Result<(), RenderError> {
386        let minus_rect = self.minus_rect();
387        let plus_rect = self.plus_rect();
388        let value_rect = self.value_rect();
389
390        let minus = theme.button(ButtonClass::Standard, self.side_status(Side::Minus));
391        let plus = theme.button(ButtonClass::Standard, self.side_status(Side::Plus));
392
393        if let Some(bg) = minus.background {
394            renderer.fill_rect(minus_rect, bg)?;
395        }
396        if let Some(border) = minus.border {
397            renderer.stroke_rect(minus_rect, border)?;
398        }
399        if let Some(bg) = plus.background {
400            renderer.fill_rect(plus_rect, bg)?;
401        }
402        if let Some(border) = plus.border {
403            renderer.stroke_rect(plus_rect, border)?;
404        }
405
406        renderer.fill_rect(value_rect, theme.background.base)?;
407
408        let body = theme.typography.body;
409        let glyph_y = |rect: Rectangle| {
410            rect.top_left.y
411                + (rect.size.height / 2) as i32
412                + (body.character_size.height / 3) as i32
413        };
414
415        renderer.draw_text(
416            "-",
417            Point::new(
418                minus_rect.top_left.x + (minus_rect.size.width / 2) as i32,
419                glyph_y(minus_rect),
420            ),
421            body,
422            minus.text,
423            Alignment::Center,
424        )?;
425        renderer.draw_text(
426            "+",
427            Point::new(
428                plus_rect.top_left.x + (plus_rect.size.width / 2) as i32,
429                glyph_y(plus_rect),
430            ),
431            body,
432            plus.text,
433            Alignment::Center,
434        )?;
435
436        let label_owned;
437        let label: &str = match self.display.as_deref() {
438            Some(s) => s,
439            None => {
440                label_owned = format!("{}", self.value);
441                &label_owned
442            }
443        };
444        renderer.draw_text(
445            label,
446            Point::new(
447                value_rect.top_left.x + (value_rect.size.width / 2) as i32,
448                glyph_y(value_rect),
449            ),
450            body,
451            theme.background.on_base,
452            Alignment::Center,
453        )?;
454
455        Ok(())
456    }
457}