1use super::Widget;
20use alloc::{boxed::Box, format, string::String, vec::Vec};
21use core::marker::PhantomData;
22use embedded_graphics::{
23 pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
24};
25use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase};
26use zest_theme::{ButtonCatalog, ButtonClass, Status, Theme};
27
28const HEADER_H: u32 = 24;
29const DOW_H: u32 = 16;
30const NAV_W: u32 = 36;
31const WEEK_ROWS: u32 = 6;
32const COLS: u32 = 7;
33
34const DAY_HOUR_H: u32 = 18;
35const DAY_LABEL_W: u32 = 40;
36
37#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
39pub enum CalendarMode {
40 #[default]
42 Month,
43 Day,
47}
48
49#[derive(Clone, Debug)]
54pub struct CalendarEvent<C: PixelColor> {
55 pub day: u32,
57 pub color: C,
59 pub time: Option<(u8, u8)>,
61 pub label: String,
64}
65
66pub struct Calendar<'a, C: PixelColor, M: Clone> {
68 rect: Rectangle,
69 year: i32,
70 month: u32,
71 month_name: String,
72 days_in_month: u32,
73 first_dow: u8,
74 selected_day: Option<u32>,
75 today: Option<u32>,
76 events: Vec<CalendarEvent<C>>,
77 on_select: Option<Box<dyn Fn(u32) -> M + 'a>>,
78 on_prev: Option<M>,
79 on_next: Option<M>,
80 width: Length,
81 height: Length,
82 pressed_day: Option<u32>,
83 pressed_nav: Option<NavZone>,
84 mode: CalendarMode,
85 day_label: String,
86 _phantom: PhantomData<C>,
87}
88
89impl<'a, C: PixelColor, M: Clone> Calendar<'a, C, M> {
90 pub fn new(year: i32, month: u32, month_name: impl Into<String>) -> Self {
93 Self {
94 rect: Rectangle::zero(),
95 year,
96 month,
97 month_name: month_name.into(),
98 days_in_month: 30,
99 first_dow: 0,
100 selected_day: None,
101 today: None,
102 events: Vec::new(),
103 on_select: None,
104 on_prev: None,
105 on_next: None,
106 width: Length::Fill,
107 height: Length::Fill,
108 pressed_day: None,
109 pressed_nav: None,
110 mode: CalendarMode::Month,
111 day_label: String::new(),
112 _phantom: PhantomData,
113 }
114 }
115
116 #[must_use]
118 pub fn mode(mut self, mode: CalendarMode) -> Self {
119 self.mode = mode;
120 self
121 }
122
123 #[must_use]
126 pub fn day_label(mut self, label: impl Into<String>) -> Self {
127 self.day_label = label.into();
128 self
129 }
130
131 #[must_use]
133 pub fn days_in_month(mut self, n: u32) -> Self {
134 self.days_in_month = n;
135 self
136 }
137
138 #[must_use]
140 pub fn first_day_of_week(mut self, d: u8) -> Self {
141 self.first_dow = d.min(6);
142 self
143 }
144
145 #[must_use]
147 pub fn selected(mut self, day: u32) -> Self {
148 self.selected_day = Some(day);
149 self
150 }
151
152 #[must_use]
155 pub fn today(mut self, day: u32) -> Self {
156 self.today = Some(day);
157 self
158 }
159
160 #[must_use]
162 pub fn events(mut self, events: impl IntoIterator<Item = CalendarEvent<C>>) -> Self {
163 self.events = events.into_iter().collect();
164 self
165 }
166
167 #[must_use]
169 pub fn event(mut self, day: u32, color: C) -> Self {
170 self.events.push(CalendarEvent {
171 day,
172 color,
173 time: None,
174 label: String::new(),
175 });
176 self
177 }
178
179 #[must_use]
181 pub fn on_select<F>(mut self, f: F) -> Self
182 where
183 F: Fn(u32) -> M + 'a,
184 {
185 self.on_select = Some(Box::new(f));
186 self
187 }
188
189 #[must_use]
191 pub fn on_prev(mut self, msg: M) -> Self {
192 self.on_prev = Some(msg);
193 self
194 }
195
196 #[must_use]
198 pub fn on_next(mut self, msg: M) -> Self {
199 self.on_next = Some(msg);
200 self
201 }
202
203 #[must_use]
205 pub fn width(mut self, w: impl Into<Length>) -> Self {
206 self.width = w.into();
207 self
208 }
209
210 #[must_use]
212 pub fn height(mut self, h: impl Into<Length>) -> Self {
213 self.height = h.into();
214 self
215 }
216
217 #[must_use]
219 pub fn year(&self) -> i32 {
220 self.year
221 }
222
223 #[must_use]
225 pub fn month(&self) -> u32 {
226 self.month
227 }
228
229 fn header_rect(&self) -> Rectangle {
230 Rectangle::new(
231 self.rect.top_left,
232 Size::new(self.rect.size.width, HEADER_H),
233 )
234 }
235
236 fn prev_rect(&self) -> Rectangle {
237 Rectangle::new(self.rect.top_left, Size::new(NAV_W, HEADER_H))
238 }
239
240 fn next_rect(&self) -> Rectangle {
241 let w = self.rect.size.width;
242 Rectangle::new(
243 self.rect.top_left + Point::new(w.saturating_sub(NAV_W) as i32, 0),
244 Size::new(NAV_W, HEADER_H),
245 )
246 }
247
248 fn dow_rect(&self) -> Rectangle {
249 Rectangle::new(
250 self.rect.top_left + Point::new(0, HEADER_H as i32),
251 Size::new(self.rect.size.width, DOW_H),
252 )
253 }
254
255 fn grid_rect(&self) -> Rectangle {
256 let top = HEADER_H + DOW_H;
257 Rectangle::new(
258 self.rect.top_left + Point::new(0, top as i32),
259 Size::new(
260 self.rect.size.width,
261 self.rect.size.height.saturating_sub(top),
262 ),
263 )
264 }
265
266 fn cell_size(&self) -> Size {
267 let grid = self.grid_rect();
268 Size::new(grid.size.width / COLS, grid.size.height / WEEK_ROWS)
269 }
270
271 fn cell_rect(&self, row: u32, col: u32) -> Rectangle {
272 let grid = self.grid_rect();
273 let cell = self.cell_size();
274 Rectangle::new(
275 grid.top_left + Point::new((col * cell.width) as i32, (row * cell.height) as i32),
276 cell,
277 )
278 }
279
280 fn day_at(&self, idx: u32) -> Option<u32> {
281 let first = self.first_dow as u32;
282 if idx < first {
283 return None;
284 }
285 let day = idx - first + 1;
286 if day > self.days_in_month {
287 None
288 } else {
289 Some(day)
290 }
291 }
292
293 fn hit_test_day(&self, point: Point) -> Option<u32> {
294 let grid = self.grid_rect();
295 if !rect_contains(grid, point) {
296 return None;
297 }
298 let cell = self.cell_size();
299 if cell.width == 0 || cell.height == 0 {
300 return None;
301 }
302 let col = ((point.x - grid.top_left.x) as u32 / cell.width).min(COLS - 1);
303 let row = ((point.y - grid.top_left.y) as u32 / cell.height).min(WEEK_ROWS - 1);
304 self.day_at(row * COLS + col)
305 }
306
307 fn hit_test_nav(&self, point: Point) -> Option<NavZone> {
308 if rect_contains(self.prev_rect(), point) {
309 Some(NavZone::Prev)
310 } else if rect_contains(self.next_rect(), point) {
311 Some(NavZone::Next)
312 } else {
313 None
314 }
315 }
316
317 fn day_intrinsic_height(&self) -> u32 {
318 HEADER_H + 24 * DAY_HOUR_H
319 }
320
321 fn day_hour_rect(&self, hour: u32) -> Rectangle {
322 Rectangle::new(
323 self.rect.top_left + Point::new(0, (HEADER_H + hour * DAY_HOUR_H) as i32),
324 Size::new(self.rect.size.width, DAY_HOUR_H),
325 )
326 }
327
328 fn hit_test_hour(&self, point: Point) -> Option<u32> {
329 if point.y < self.rect.top_left.y + HEADER_H as i32 {
330 return None;
331 }
332 if point.x < self.rect.top_left.x
333 || point.x >= self.rect.top_left.x + self.rect.size.width as i32
334 {
335 return None;
336 }
337 let offset = (point.y - self.rect.top_left.y) as u32;
338 if offset < HEADER_H {
339 return None;
340 }
341 let hour = (offset - HEADER_H) / DAY_HOUR_H;
342 if hour < 24 { Some(hour) } else { None }
343 }
344}
345
346#[derive(Copy, Clone, Debug, PartialEq, Eq)]
347enum NavZone {
348 Prev,
349 Next,
350}
351
352fn rect_contains(rect: Rectangle, p: Point) -> bool {
353 let top_left = rect.top_left;
354 let bottom_right = top_left + Point::new(rect.size.width as i32, rect.size.height as i32);
355 p.x >= top_left.x && p.x < bottom_right.x && p.y >= top_left.y && p.y < bottom_right.y
356}
357
358impl<'a, C: PixelColor, M: Clone> Widget<C, M> for Calendar<'a, C, M> {
359 fn measure(&mut self, constraints: Constraints) -> Size {
360 let intrinsic_h = match self.mode {
361 CalendarMode::Month => HEADER_H + DOW_H + WEEK_ROWS * 24,
362 CalendarMode::Day => self.day_intrinsic_height(),
363 };
364 let w = self
365 .width
366 .resolve(constraints.max.width, constraints.max.width);
367 let h = match self.mode {
368 CalendarMode::Day => intrinsic_h,
369 CalendarMode::Month => self.height.resolve(intrinsic_h, constraints.max.height),
370 };
371 constraints.clamp(Size::new(w, h))
372 }
373
374 fn preferred_size(&self) -> (Length, Length) {
375 match self.mode {
376 CalendarMode::Day => (self.width, Length::Shrink),
377 CalendarMode::Month => (self.width, self.height),
378 }
379 }
380
381 fn arrange(&mut self, rect: Rectangle) {
382 self.rect = rect;
383 }
384
385 fn rect(&self) -> Rectangle {
386 self.rect
387 }
388
389 fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
390 match phase {
391 TouchPhase::Down => {
392 self.pressed_nav = self.hit_test_nav(point);
393 self.pressed_day = if self.pressed_nav.is_some() {
394 None
395 } else {
396 match self.mode {
397 CalendarMode::Month => self.hit_test_day(point),
398 CalendarMode::Day => self.hit_test_hour(point),
399 }
400 };
401 None
402 }
403 TouchPhase::Moved => {
404 let now = match self.mode {
405 CalendarMode::Month => self.hit_test_day(point),
406 CalendarMode::Day => self.hit_test_hour(point),
407 };
408 if self.pressed_day.is_some() && now != self.pressed_day {
409 self.pressed_day = None;
410 }
411 if self.pressed_nav.is_some() && self.hit_test_nav(point) != self.pressed_nav {
412 self.pressed_nav = None;
413 }
414 None
415 }
416 TouchPhase::Up => {
417 let nav_now = self.hit_test_nav(point);
418 let nav_pressed = self.pressed_nav.take();
419 if let (Some(z), Some(p)) = (nav_now, nav_pressed) {
420 if z == p {
421 return match z {
422 NavZone::Prev => self.on_prev.clone(),
423 NavZone::Next => self.on_next.clone(),
424 };
425 }
426 }
427 let cell = match self.mode {
428 CalendarMode::Month => self.hit_test_day(point),
429 CalendarMode::Day => self.hit_test_hour(point),
430 };
431 let pressed = self.pressed_day.take();
432 if let (Some(d), Some(p), Some(cb)) = (cell, pressed, self.on_select.as_ref()) {
433 if d == p {
434 return Some(cb(d));
435 }
436 }
437 None
438 }
439 }
440 }
441
442 fn mark_pressed(&mut self, point: Point) {
443 if self.pressed_nav.is_none() {
444 self.pressed_nav = self.hit_test_nav(point);
445 }
446 if self.pressed_nav.is_none() && self.pressed_day.is_none() {
447 self.pressed_day = match self.mode {
448 CalendarMode::Month => self.hit_test_day(point),
449 CalendarMode::Day => self.hit_test_hour(point),
450 };
451 }
452 }
453
454 fn draw<'t>(
455 &self,
456 renderer: &mut dyn Renderer<C>,
457 theme: &Theme<'t, C>,
458 ) -> Result<(), RenderError> {
459 renderer.fill_rect(self.rect, theme.background.base)?;
460 self.draw_header(renderer, theme)?;
461 match self.mode {
462 CalendarMode::Month => self.draw_month_grid(renderer, theme)?,
463 CalendarMode::Day => self.draw_day_schedule(renderer, theme)?,
464 }
465 Ok(())
466 }
467}
468
469impl<'a, C: PixelColor, M: Clone> Calendar<'a, C, M> {
470 fn draw_header<'t>(
471 &self,
472 renderer: &mut dyn Renderer<C>,
473 theme: &Theme<'t, C>,
474 ) -> Result<(), RenderError> {
475 let header = self.header_rect();
476 renderer.fill_rect(header, theme.primary.base)?;
477
478 let prev_rect = self.prev_rect();
479 let next_rect = self.next_rect();
480 let prev_status = if self.pressed_nav == Some(NavZone::Prev) {
481 Status::Pressed
482 } else if self.on_prev.is_some() {
483 Status::Active
484 } else {
485 Status::Disabled
486 };
487 let next_status = if self.pressed_nav == Some(NavZone::Next) {
488 Status::Pressed
489 } else if self.on_next.is_some() {
490 Status::Active
491 } else {
492 Status::Disabled
493 };
494 let prev = theme.button(ButtonClass::Standard, prev_status);
495 let next = theme.button(ButtonClass::Standard, next_status);
496 if let Some(bg) = prev.background {
497 renderer.fill_rect(prev_rect, bg)?;
498 }
499 if let Some(border) = prev.border {
500 renderer.stroke_rect(prev_rect, border)?;
501 }
502 if let Some(bg) = next.background {
503 renderer.fill_rect(next_rect, bg)?;
504 }
505 if let Some(border) = next.border {
506 renderer.stroke_rect(next_rect, border)?;
507 }
508 let body = theme.typography.body;
509 let baseline_y = header.top_left.y
510 + (header.size.height / 2) as i32
511 + (body.character_size.height / 3) as i32;
512 renderer.draw_text(
513 "<",
514 Point::new(
515 prev_rect.top_left.x + (prev_rect.size.width / 2) as i32,
516 baseline_y,
517 ),
518 body,
519 prev.text,
520 Alignment::Center,
521 )?;
522 renderer.draw_text(
523 ">",
524 Point::new(
525 next_rect.top_left.x + (next_rect.size.width / 2) as i32,
526 baseline_y,
527 ),
528 body,
529 next.text,
530 Alignment::Center,
531 )?;
532 let label = match self.mode {
533 CalendarMode::Month => self.month_name.as_str(),
534 CalendarMode::Day => self.day_label.as_str(),
535 };
536 renderer.draw_text(
537 label,
538 Point::new(
539 header.top_left.x + (header.size.width / 2) as i32,
540 baseline_y,
541 ),
542 body,
543 theme.primary.on_base,
544 Alignment::Center,
545 )?;
546 Ok(())
547 }
548
549 fn draw_month_grid<'t>(
550 &self,
551 renderer: &mut dyn Renderer<C>,
552 theme: &Theme<'t, C>,
553 ) -> Result<(), RenderError> {
554 let dow = self.dow_rect();
555 let cell_w = dow.size.width / COLS;
556 let names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
557 for (i, name) in names.iter().enumerate() {
558 let x = dow.top_left.x + (i as i32) * cell_w as i32 + (cell_w / 2) as i32;
559 let y = dow.top_left.y
560 + (dow.size.height / 2) as i32
561 + (theme.typography.caption.character_size.height / 3) as i32;
562 renderer.draw_text(
563 name,
564 Point::new(x, y),
565 theme.typography.caption,
566 theme.palette.neutral_2,
567 Alignment::Center,
568 )?;
569 }
570
571 for idx in 0..(COLS * WEEK_ROWS) {
572 let row = idx / COLS;
573 let col = idx % COLS;
574 let cell = self.cell_rect(row, col);
575 let day = match self.day_at(idx) {
576 Some(d) => d,
577 None => continue,
578 };
579
580 let is_selected = Some(day) == self.selected_day;
581 let is_today = Some(day) == self.today;
582 let is_pressed = Some(day) == self.pressed_day;
583
584 if is_selected {
585 let acc = theme.button(ButtonClass::Suggested, Status::Active);
586 if let Some(bg) = acc.background {
587 renderer.fill_rect(cell, bg)?;
588 }
589 } else if is_pressed {
590 let std = theme.button(ButtonClass::Standard, Status::Pressed);
591 if let Some(bg) = std.background {
592 renderer.fill_rect(cell, bg)?;
593 }
594 }
595
596 if is_today && !is_selected {
597 renderer.stroke_rect(cell, theme.accent.base)?;
598 }
599
600 let text_color = if is_selected {
601 theme.button(ButtonClass::Suggested, Status::Active).text
602 } else {
603 theme.background.on_base
604 };
605 let label = format!("{day}");
606 renderer.draw_text(
607 &label,
608 Point::new(
609 cell.top_left.x + (cell.size.width / 2) as i32,
610 cell.top_left.y
611 + (cell.size.height / 2) as i32
612 + (theme.typography.body.character_size.height / 3) as i32,
613 ),
614 theme.typography.body,
615 text_color,
616 Alignment::Center,
617 )?;
618
619 let mut dots = self.events.iter().filter(|e| e.day == day);
620 let dot_y = cell.top_left.y + cell.size.height as i32 - 4;
621 let mut dot_x = cell.top_left.x + (cell.size.width / 2) as i32 - 6;
622 for _ in 0..3 {
623 let Some(ev) = dots.next() else { break };
624 renderer.fill_circle(Point::new(dot_x, dot_y), 1, ev.color)?;
625 dot_x += 5;
626 }
627 }
628 Ok(())
629 }
630
631 fn draw_day_schedule<'t>(
632 &self,
633 renderer: &mut dyn Renderer<C>,
634 theme: &Theme<'t, C>,
635 ) -> Result<(), RenderError> {
636 let body = theme.typography.body;
637 let baseline_off = (body.character_size.height / 3) as i32;
638 for hour in 0..24u32 {
639 let row = self.day_hour_rect(hour);
640 renderer.fill_rect(
642 Rectangle::new(
643 Point::new(row.top_left.x, row.top_left.y + row.size.height as i32 - 1),
644 Size::new(row.size.width, 1),
645 ),
646 theme.palette.neutral_2,
647 )?;
648 if self.pressed_day == Some(hour) {
649 let s = theme.button(ButtonClass::Standard, Status::Pressed);
650 if let Some(bg) = s.background {
651 renderer.fill_rect(row, bg)?;
652 }
653 }
654 let label = format!("{hour:02}:00");
655 renderer.draw_text(
656 &label,
657 Point::new(
658 row.top_left.x + 4,
659 row.top_left.y + (row.size.height / 2) as i32 + baseline_off,
660 ),
661 theme.typography.caption,
662 theme.palette.neutral_2,
663 Alignment::Left,
664 )?;
665 }
666
667 for ev in &self.events {
669 let Some((h, m)) = ev.time else { continue };
670 if h as u32 >= 24 {
671 continue;
672 }
673 let row = self.day_hour_rect(h as u32);
674 let bar_y_offset =
675 ((m as i32) * (DAY_HOUR_H as i32) / 60).clamp(0, DAY_HOUR_H as i32 - 4);
676 let bar = Rectangle::new(
677 Point::new(
678 row.top_left.x + DAY_LABEL_W as i32,
679 row.top_left.y + bar_y_offset,
680 ),
681 Size::new(
682 row.size.width.saturating_sub(DAY_LABEL_W + 4),
683 DAY_HOUR_H.saturating_sub(2),
684 ),
685 );
686 renderer.fill_rect(bar, ev.color)?;
687 renderer.draw_text(
693 &ev.label,
694 Point::new(
695 bar.top_left.x + 4,
696 bar.top_left.y + (bar.size.height / 2) as i32 + baseline_off,
697 ),
698 theme.typography.caption,
699 theme.background.base,
700 Alignment::Left,
701 )?;
702 }
703 Ok(())
704 }
705}