1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum PickerError {
25 RenderError,
27 InvalidField,
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
33pub enum TimeFormat {
34 #[default]
36 Hour12,
37 Hour24,
39}
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
43pub enum TimePickerField {
44 #[default]
46 Hour,
47 Minute,
49 Period,
51}
52
53#[derive(Clone, Debug)]
55pub struct TimePickerWidget {
56 pub hour: u8,
58 pub minute: u8,
60 pub is_pm: bool,
62 pub format: TimeFormat,
64 pub focused_field: TimePickerField,
66 pub background_color: Rgb565,
68 pub text_color: Rgb565,
70 pub focus_color: Rgb565,
72 pub focus_bg_color: Rgb565,
74 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Clone, Debug)]
315pub struct NumberPickerWidget {
316 pub min: i32,
318 pub max: i32,
320 pub value: i32,
322 pub step: i32,
324 pub suffix: String<8>,
326 pub is_focused: bool,
328}
329
330impl NumberPickerWidget {
331 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 pub fn increment(&mut self) {
348 self.value = (self.value + self.step).min(self.max);
349 }
350
351 pub fn decrement(&mut self) {
353 self.value = (self.value - self.step).max(self.min);
354 }
355
356 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}