Skip to main content

zest_widget/widget/
calendar.rs

1//! Month-view calendar with prev/next nav, selected-day highlight,
2//! and per-day event-color dots.
3//!
4//! Date math lives in the caller — this widget asks for
5//! `days_in_month` and `first_day_of_week` (0 = Sun … 6 = Sat) plus a
6//! flat list of `(day, color)` event markers. That keeps the widget
7//! independent of any date library (`chrono`, `time`, hand-rolled) and
8//! the no_std story clean.
9//!
10//! Interaction emits user messages via builder callbacks:
11//! - tap on a day cell → `on_select(day)` is invoked
12//! - tap on the left header arrow → `on_prev` message fires
13//! - tap on the right header arrow → `on_next` message fires
14//!
15//! Layout (assumes the widget gets roughly 320x208 on a 320x240
16//! panel with a tab bar): 24-px header strip, 16-px day-of-week row,
17//! six week rows filling the rest.
18
19use 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/// Which view mode the calendar should render.
38#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
39pub enum CalendarMode {
40    /// Month grid (default).
41    #[default]
42    Month,
43    /// 24-row day schedule. Caller usually wraps the calendar in a
44    /// `Scrollable` since the day view's intrinsic height (≈ 24 +
45    /// 24×18 ≈ 456 px) is taller than typical embedded viewports.
46    Day,
47}
48
49/// One event marker on the calendar — a day-of-month plus an
50/// indicator color. In Day-view mode, the optional `time` (hour 0–23,
51/// minute 0–59) places the event in its hour row; events with `time =
52/// None` are skipped in Day view.
53#[derive(Clone, Debug)]
54pub struct CalendarEvent<C: PixelColor> {
55    /// Day-of-month, 1-based.
56    pub day: u32,
57    /// Color of the dot drawn inside the day's cell.
58    pub color: C,
59    /// Optional `(hour, minute)` for Day-view placement.
60    pub time: Option<(u8, u8)>,
61    /// Label text rendered inside the event bar in Day view. Unused
62    /// in Month view (which only paints dots).
63    pub label: String,
64}
65
66/// Month-view calendar widget.
67pub 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    /// New calendar for the given month. `month_name` is rendered in
91    /// the header — supply something like `"March 2026"`.
92    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    /// Rendering mode (Month grid or Day schedule).
117    #[must_use]
118    pub fn mode(mut self, mode: CalendarMode) -> Self {
119        self.mode = mode;
120        self
121    }
122
123    /// Text label rendered in the header in Day-view mode
124    /// (e.g. `"Mon, Mar 14"`). Ignored in Month mode.
125    #[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    /// Number of days in this month (caller's date math).
132    #[must_use]
133    pub fn days_in_month(mut self, n: u32) -> Self {
134        self.days_in_month = n;
135        self
136    }
137
138    /// Day-of-week of the 1st of this month. 0=Sun … 6=Sat.
139    #[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    /// Highlight a particular day as the user's current selection.
146    #[must_use]
147    pub fn selected(mut self, day: u32) -> Self {
148        self.selected_day = Some(day);
149        self
150    }
151
152    /// Mark a day as "today" (subtle ring around the cell).
153    /// Optional; pass when you want today to stand out without selecting it.
154    #[must_use]
155    pub fn today(mut self, day: u32) -> Self {
156        self.today = Some(day);
157        self
158    }
159
160    /// Replace the event list outright.
161    #[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    /// Add one event marker (no time, empty label).
168    #[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    /// Callback fired when a day cell is tapped.
180    #[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    /// Message fired when the previous-month arrow is tapped.
190    #[must_use]
191    pub fn on_prev(mut self, msg: M) -> Self {
192        self.on_prev = Some(msg);
193        self
194    }
195
196    /// Message fired when the next-month arrow is tapped.
197    #[must_use]
198    pub fn on_next(mut self, msg: M) -> Self {
199        self.on_next = Some(msg);
200        self
201    }
202
203    /// Width sizing intent.
204    #[must_use]
205    pub fn width(mut self, w: impl Into<Length>) -> Self {
206        self.width = w.into();
207        self
208    }
209
210    /// Height sizing intent.
211    #[must_use]
212    pub fn height(mut self, h: impl Into<Length>) -> Self {
213        self.height = h.into();
214        self
215    }
216
217    /// Year currently displayed.
218    #[must_use]
219    pub fn year(&self) -> i32 {
220        self.year
221    }
222
223    /// Month currently displayed (1–12).
224    #[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            // Bottom divider for each row.
641            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        // Event bars in their hour rows.
668        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            // Label rendered in body font, on top of the colored bar.
688            // Pick a contrasting color: just use background.base (theme
689            // primary) — for dark themes this is dark text, for light
690            // themes this is light text, which usually contrasts with
691            // the saturated event color.
692            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}