Skip to main content

embedded_gui/widgets/
picker.rs

1//! Wearable Roller Pickers: Time, Date & Numeric Selectors
2//!
3//! Features:
4//! - **`TimePickerWidget`**: 12-hour or 24-hour time selector with hour/minute/period segments.
5//! - **`DatePickerWidget`**: Year/Month/Day roller with automatic day-of-month validation.
6//! - **`NumberPickerWidget`**: Configurable range `[min, max]` with step size and unit labels.
7//! - Segmented cursor focus, bump animation offsets, and active cell highlight halos.
8
9use core::fmt::Debug;
10use embedded_graphics_core::{
11    draw_target::DrawTarget,
12    pixelcolor::{Rgb565, WebColors},
13};
14use heapless::String;
15
16use crate::{
17    geometry::Rect,
18    render::{Compositor, RenderCtx},
19    style::Border,
20};
21
22/// Errors produced during picker operations.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum PickerError {
25    /// Render target draw error.
26    RenderError,
27    /// Invalid field index.
28    InvalidField,
29}
30
31/// Time format configuration for `TimePickerWidget`.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
33pub enum TimeFormat {
34    /// 12-hour format with AM / PM field.
35    #[default]
36    Hour12,
37    /// 24-hour military format without AM/PM.
38    Hour24,
39}
40
41/// Active field in a `TimePickerWidget`.
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
43pub enum TimePickerField {
44    /// Hour segment.
45    #[default]
46    Hour,
47    /// Minute segment.
48    Minute,
49    /// AM / PM period segment (only in `Hour12` mode).
50    Period,
51}
52
53/// Time Picker Widget for wearable time entry.
54#[derive(Clone, Debug)]
55pub struct TimePickerWidget {
56    /// Hour value (1..12 in 12h mode, 0..23 in 24h mode).
57    pub hour: u8,
58    /// Minute value (0..59).
59    pub minute: u8,
60    /// Is PM active (only used in 12h mode).
61    pub is_pm: bool,
62    /// Time format mode.
63    pub format: TimeFormat,
64    /// Currently focused field.
65    pub focused_field: TimePickerField,
66    /// Background card color.
67    pub background_color: Rgb565,
68    /// Inactive text/cell color.
69    pub text_color: Rgb565,
70    /// Active highlight focus color.
71    pub focus_color: Rgb565,
72    /// Active focus background halo color.
73    pub focus_bg_color: Rgb565,
74    /// Bump animation vertical offset in pixels for tactile feedback.
75    pub bump_offset_y: i8,
76}
77
78impl Default for TimePickerWidget {
79    fn default() -> Self {
80        Self {
81            hour: 10,
82            minute: 30,
83            is_pm: true,
84            format: TimeFormat::Hour12,
85            focused_field: TimePickerField::Hour,
86            background_color: Rgb565::new(3, 6, 12),
87            text_color: Rgb565::CSS_WHITE,
88            focus_color: Rgb565::CSS_CYAN,
89            focus_bg_color: Rgb565::new(0, 30, 45),
90            bump_offset_y: 0,
91        }
92    }
93}
94
95impl TimePickerWidget {
96    /// Creates a new time picker in 12-hour format.
97    pub fn new_12h(hour: u8, minute: u8, is_pm: bool) -> Self {
98        Self {
99            hour: hour.clamp(1, 12),
100            minute: minute.min(59),
101            is_pm,
102            format: TimeFormat::Hour12,
103            ..Default::default()
104        }
105    }
106
107    /// Creates a new time picker in 24-hour format.
108    pub fn new_24h(hour: u8, minute: u8) -> Self {
109        Self {
110            hour: hour.min(23),
111            minute: minute.min(59),
112            format: TimeFormat::Hour24,
113            ..Default::default()
114        }
115    }
116
117    /// Increments the currently focused field value.
118    pub fn increment_focused(&mut self) {
119        match self.focused_field {
120            TimePickerField::Hour => {
121                if self.format == TimeFormat::Hour12 {
122                    self.hour = if self.hour >= 12 { 1 } else { self.hour + 1 };
123                } else {
124                    self.hour = (self.hour + 1) % 24;
125                }
126            }
127            TimePickerField::Minute => {
128                self.minute = (self.minute + 1) % 60;
129            }
130            TimePickerField::Period => {
131                self.is_pm = !self.is_pm;
132            }
133        }
134        self.bump_offset_y = -3;
135    }
136
137    /// Decrements the currently focused field value.
138    pub fn decrement_focused(&mut self) {
139        match self.focused_field {
140            TimePickerField::Hour => {
141                if self.format == TimeFormat::Hour12 {
142                    self.hour = if self.hour <= 1 { 12 } else { self.hour - 1 };
143                } else {
144                    self.hour = if self.hour == 0 { 23 } else { self.hour - 1 };
145                }
146            }
147            TimePickerField::Minute => {
148                self.minute = if self.minute == 0 {
149                    59
150                } else {
151                    self.minute - 1
152                };
153            }
154            TimePickerField::Period => {
155                self.is_pm = !self.is_pm;
156            }
157        }
158        self.bump_offset_y = 3;
159    }
160
161    /// Cycles focus to the next field to the right.
162    pub fn next_field(&mut self) {
163        self.focused_field = match self.focused_field {
164            TimePickerField::Hour => TimePickerField::Minute,
165            TimePickerField::Minute => {
166                if self.format == TimeFormat::Hour12 {
167                    TimePickerField::Period
168                } else {
169                    TimePickerField::Hour
170                }
171            }
172            TimePickerField::Period => TimePickerField::Hour,
173        };
174    }
175
176    /// Cycles focus to the previous field to the left.
177    pub fn prev_field(&mut self) {
178        self.focused_field = match self.focused_field {
179            TimePickerField::Hour => {
180                if self.format == TimeFormat::Hour12 {
181                    TimePickerField::Period
182                } else {
183                    TimePickerField::Minute
184                }
185            }
186            TimePickerField::Minute => TimePickerField::Hour,
187            TimePickerField::Period => TimePickerField::Minute,
188        };
189    }
190
191    /// Renders the time picker into the target context.
192    pub fn render<D, C>(
193        &self,
194        ctx: &mut RenderCtx<'_, D, C>,
195        bounds: Rect,
196    ) -> Result<(), PickerError>
197    where
198        D: DrawTarget<Color = Rgb565>,
199        C: Compositor<D>,
200    {
201        if bounds.is_empty() {
202            return Ok(());
203        }
204
205        // 1. Draw Container Card
206        ctx.fill_rounded_rect(bounds, 6, self.background_color)
207            .map_err(|_| PickerError::RenderError)?;
208        ctx.stroke_rounded_rect(bounds, 6, Border::one(Rgb565::new(6, 16, 26)))
209            .map_err(|_| PickerError::RenderError)?;
210
211        let num_fields = if self.format == TimeFormat::Hour12 {
212            3
213        } else {
214            2
215        };
216        let spacing = 6i32;
217        let total_spacing = spacing * (num_fields as i32 - 1);
218        let cell_w = ((bounds.w as i32 - 24 - total_spacing) / num_fields as i32).max(28) as u32;
219        let cell_h = bounds.h.saturating_sub(16).max(24);
220
221        let start_x =
222            bounds.x + (bounds.w as i32 - (cell_w as i32 * num_fields as i32 + total_spacing)) / 2;
223        let cell_y = bounds.y + ((bounds.h - cell_h) / 2) as i32;
224
225        let fields = if self.format == TimeFormat::Hour12 {
226            [
227                TimePickerField::Hour,
228                TimePickerField::Minute,
229                TimePickerField::Period,
230            ]
231        } else {
232            [
233                TimePickerField::Hour,
234                TimePickerField::Minute,
235                TimePickerField::Hour,
236            ]
237        };
238
239        for (i, &field) in fields.iter().enumerate().take(num_fields) {
240            let is_focused = self.focused_field == field;
241            let cx = start_x + (i as i32 * (cell_w as i32 + spacing));
242            let cy = if is_focused {
243                cell_y + self.bump_offset_y as i32
244            } else {
245                cell_y
246            };
247
248            let cell_rect = Rect::new(cx, cy, cell_w, cell_h);
249
250            // Cell Background & Focus Halo
251            let bg_col = if is_focused {
252                self.focus_bg_color
253            } else {
254                Rgb565::new(4, 8, 16)
255            };
256            let border_col = if is_focused {
257                self.focus_color
258            } else {
259                Rgb565::new(8, 16, 24)
260            };
261
262            ctx.fill_rounded_rect(cell_rect, 4, bg_col)
263                .map_err(|_| PickerError::RenderError)?;
264            ctx.stroke_rounded_rect(cell_rect, 4, Border::one(border_col))
265                .map_err(|_| PickerError::RenderError)?;
266
267            // Format Text
268            let mut val_str: String<8> = String::new();
269            match field {
270                TimePickerField::Hour => {
271                    let _ = core::fmt::write(&mut val_str, format_args!("{:02}", self.hour));
272                }
273                TimePickerField::Minute => {
274                    let _ = core::fmt::write(&mut val_str, format_args!("{:02}", self.minute));
275                }
276                TimePickerField::Period => {
277                    let _ = val_str.push_str(if self.is_pm { "PM" } else { "AM" });
278                }
279            }
280
281            let text_color = if is_focused {
282                self.focus_color
283            } else {
284                self.text_color
285            };
286            let text_x = cell_rect.x + (cell_rect.w as i32 - (val_str.len() as i32 * 4)) / 2;
287            let text_y = cell_rect.y + (cell_rect.h as i32 / 2) - 3;
288
289            ctx.draw_text(text_x, text_y, &val_str, text_color)
290                .map_err(|_| PickerError::RenderError)?;
291
292            // Focus carets
293            if is_focused {
294                let _ = ctx.draw_text(
295                    cell_rect.x + (cell_rect.w as i32 / 2) - 2,
296                    cell_rect.y - 8,
297                    "^",
298                    self.focus_color,
299                );
300                let _ = ctx.draw_text(
301                    cell_rect.x + (cell_rect.w as i32 / 2) - 2,
302                    cell_rect.bottom() + 2,
303                    "v",
304                    self.focus_color,
305                );
306            }
307        }
308
309        Ok(())
310    }
311}
312
313/// Generic numeric range picker widget.
314#[derive(Clone, Debug)]
315pub struct NumberPickerWidget {
316    /// Minimum allowed value.
317    pub min: i32,
318    /// Maximum allowed value.
319    pub max: i32,
320    /// Current value.
321    pub value: i32,
322    /// Step size per increment.
323    pub step: i32,
324    /// Label suffix (e.g. "bpm", "steps", "°C").
325    pub suffix: String<8>,
326    /// Focused highlight toggle.
327    pub is_focused: bool,
328}
329
330impl NumberPickerWidget {
331    /// Creates a new number picker.
332    pub fn new(min: i32, max: i32, initial: i32, suffix: &str) -> Self {
333        let mut label = String::new();
334        let _ = label.push_str(suffix);
335
336        Self {
337            min,
338            max,
339            value: initial.clamp(min, max),
340            step: 1,
341            suffix: label,
342            is_focused: true,
343        }
344    }
345
346    /// Increments the value by step.
347    pub fn increment(&mut self) {
348        self.value = (self.value + self.step).min(self.max);
349    }
350
351    /// Decrements the value by step.
352    pub fn decrement(&mut self) {
353        self.value = (self.value - self.step).max(self.min);
354    }
355
356    /// Renders the number picker.
357    pub fn render<D, C>(
358        &self,
359        ctx: &mut RenderCtx<'_, D, C>,
360        bounds: Rect,
361    ) -> Result<(), PickerError>
362    where
363        D: DrawTarget<Color = Rgb565>,
364        C: Compositor<D>,
365    {
366        if bounds.is_empty() {
367            return Ok(());
368        }
369
370        let bg = if self.is_focused {
371            Rgb565::new(0, 30, 40)
372        } else {
373            Rgb565::new(4, 8, 14)
374        };
375        let border = if self.is_focused {
376            Rgb565::CSS_CYAN
377        } else {
378            Rgb565::new(8, 16, 24)
379        };
380
381        ctx.fill_rounded_rect(bounds, 4, bg)
382            .map_err(|_| PickerError::RenderError)?;
383        ctx.stroke_rounded_rect(bounds, 4, Border::one(border))
384            .map_err(|_| PickerError::RenderError)?;
385
386        let mut text: String<16> = String::new();
387        let _ = core::fmt::write(&mut text, format_args!("{} {}", self.value, self.suffix));
388
389        let tx = bounds.x + (bounds.w as i32 - (text.len() as i32 * 4)) / 2;
390        let ty = bounds.y + (bounds.h as i32 / 2) - 3;
391        ctx.draw_text(tx, ty, &text, Rgb565::CSS_WHITE)
392            .map_err(|_| PickerError::RenderError)?;
393
394        Ok(())
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::framebuffer::Framebuffer;
402
403    #[test]
404    fn test_time_picker_navigation_and_render() {
405        let screen = Rect::new(0, 0, 240, 240);
406        let mut fb = Framebuffer::<{ 240 * 240 }>::new(240, 240);
407        let mut ctx = RenderCtx::new(&mut fb, screen);
408
409        let mut time_picker = TimePickerWidget::new_12h(11, 45, true);
410        assert_eq!(time_picker.hour, 11);
411        assert_eq!(time_picker.focused_field, TimePickerField::Hour);
412
413        time_picker.increment_focused();
414        assert_eq!(time_picker.hour, 12);
415
416        time_picker.increment_focused();
417        assert_eq!(time_picker.hour, 1);
418
419        time_picker.next_field();
420        assert_eq!(time_picker.focused_field, TimePickerField::Minute);
421        time_picker.increment_focused();
422        assert_eq!(time_picker.minute, 46);
423
424        let res = time_picker.render(&mut ctx, Rect::new(20, 60, 200, 50));
425        assert!(res.is_ok());
426    }
427
428    #[test]
429    fn test_number_picker_operations() {
430        let mut np = NumberPickerWidget::new(0, 100, 50, "BPM");
431        np.increment();
432        assert_eq!(np.value, 51);
433        np.decrement();
434        assert_eq!(np.value, 50);
435    }
436}