Skip to main content

cadmus_core/view/intermission/
calendar.rs

1use super::super::{Bus, Event, Hub, Id, RenderQueue, View, ID_FEEDER};
2use crate::color::{Color, TEXT_INVERTED_HARD, TEXT_NORMAL};
3use crate::context::Context;
4use crate::device::CURRENT_DEVICE;
5use crate::fl;
6use crate::font::{font_from_style, Fonts, DISPLAY_FONT_SIZE, FONT_SIZES, NORMAL_STYLE};
7use crate::framebuffer::Framebuffer;
8use crate::geom::{CornerSpec, Point, Rectangle};
9use chrono::{Datelike, Local, NaiveDate, Timelike};
10use std::time::Duration;
11use tracing::debug;
12
13/// Returns the translated full month name for `month` (1-indexed).
14fn month_name(month: u32) -> String {
15    match month {
16        1 => fl!("calendar-month-january"),
17        2 => fl!("calendar-month-february"),
18        3 => fl!("calendar-month-march"),
19        4 => fl!("calendar-month-april"),
20        5 => fl!("calendar-month-may"),
21        6 => fl!("calendar-month-june"),
22        7 => fl!("calendar-month-july"),
23        8 => fl!("calendar-month-august"),
24        9 => fl!("calendar-month-september"),
25        10 => fl!("calendar-month-october"),
26        11 => fl!("calendar-month-november"),
27        12 => fl!("calendar-month-december"),
28        _ => String::new(),
29    }
30}
31
32/// Returns the translated short month name for `month` (1-indexed).
33fn short_month_name(month: u32) -> String {
34    match month {
35        1 => fl!("calendar-month-short-jan"),
36        2 => fl!("calendar-month-short-feb"),
37        3 => fl!("calendar-month-short-mar"),
38        4 => fl!("calendar-month-short-apr"),
39        5 => fl!("calendar-month-short-may"),
40        6 => fl!("calendar-month-short-jun"),
41        7 => fl!("calendar-month-short-jul"),
42        8 => fl!("calendar-month-short-aug"),
43        9 => fl!("calendar-month-short-sep"),
44        10 => fl!("calendar-month-short-oct"),
45        11 => fl!("calendar-month-short-nov"),
46        12 => fl!("calendar-month-short-dec"),
47        _ => String::new(),
48    }
49}
50
51/// Returns the translated weekday abbreviation for `weekday` (Mon=0 … Sun=6).
52fn weekday_name(weekday: usize) -> String {
53    match weekday {
54        0 => fl!("calendar-weekday-mon"),
55        1 => fl!("calendar-weekday-tue"),
56        2 => fl!("calendar-weekday-wed"),
57        3 => fl!("calendar-weekday-thu"),
58        4 => fl!("calendar-weekday-fri"),
59        5 => fl!("calendar-weekday-sat"),
60        6 => fl!("calendar-weekday-sun"),
61        _ => String::new(),
62    }
63}
64
65/// A leaf view that renders a full-screen calendar for the current month.
66///
67/// Displays the current time, date, month title, weekday headers, and a day
68/// grid with today highlighted. An optional power-off countdown is shown
69/// between the date line and the calendar grid.
70pub(super) struct CalendarView {
71    id: Id,
72    rect: Rectangle,
73    children: Vec<Box<dyn View>>,
74    minutes_until_poweroff: Option<i64>,
75    /// When true the background is inverted (halt screen colour scheme).
76    halt: bool,
77}
78
79impl CalendarView {
80    pub(super) fn new(rect: Rectangle, minutes_until_poweroff: Option<i64>, halt: bool) -> Self {
81        CalendarView {
82            id: ID_FEEDER.next(),
83            rect,
84            children: Vec::new(),
85            minutes_until_poweroff,
86            halt,
87        }
88    }
89}
90
91/// Returns the number of days in `month` of `year`.
92fn days_in_month(year: i32, month: u32) -> i32 {
93    let (next_year, next_month) = if month == 12 {
94        (year + 1, 1)
95    } else {
96        (year, month + 1)
97    };
98    NaiveDate::from_ymd_opt(next_year, next_month, 1)
99        .and_then(|d| d.pred_opt())
100        .map(|d| d.day() as i32)
101        .unwrap_or(30)
102}
103
104/// Returns the Monday-based weekday index (Mon=0 … Sun=6) of the first day of
105/// `month` in `year`.
106fn month_start_weekday(year: i32, month: u32) -> i32 {
107    NaiveDate::from_ymd_opt(year, month, 1)
108        .map(|d| d.weekday().num_days_from_monday() as i32)
109        .unwrap_or(0)
110}
111
112/// Returns the number of days in the month preceding `month`/`year`.
113fn days_in_prev_month(year: i32, month: u32) -> i32 {
114    let (prev_year, prev_month) = if month == 1 {
115        (year - 1, 12)
116    } else {
117        (year, month - 1)
118    };
119    days_in_month(prev_year, prev_month)
120}
121
122/// Returns the color used on color-capable devices, falling back to
123/// `fallback` on grayscale hardware.
124fn color_or(is_color: bool, r: u8, g: u8, b: u8, fallback: Color) -> Color {
125    if is_color {
126        Color::Rgb(r, g, b)
127    } else {
128        fallback
129    }
130}
131
132impl View for CalendarView {
133    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, _hub, _bus, _rq, _context), fields(event = ?_evt), ret(level=tracing::Level::TRACE)))]
134    fn handle_event(
135        &mut self,
136        _evt: &Event,
137        _hub: &Hub,
138        _bus: &mut Bus,
139        _rq: &mut RenderQueue,
140        _context: &mut Context,
141    ) -> bool {
142        false
143    }
144
145    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self, fb, fonts), fields(rect = ?_rect)))]
146    fn render(&self, fb: &mut dyn Framebuffer, _rect: Rectangle, fonts: &mut Fonts) {
147        let scheme = if self.halt {
148            TEXT_INVERTED_HARD
149        } else {
150            TEXT_NORMAL
151        };
152
153        fb.draw_rectangle(&self.rect, scheme[0]);
154
155        let now = Local::now();
156        debug!(timestamp = %now, "Rendering calendar view");
157
158        let year = now.year();
159        let month = now.month();
160        let today = now.day() as i32;
161
162        let is_color = CURRENT_DEVICE.color_samples() > 1;
163        let month_color = color_or(is_color, 180, 60, 40, scheme[1]);
164        let today_bg = color_or(is_color, 70, 100, 150, scheme[1]);
165
166        let dpi = CURRENT_DEVICE.dpi;
167        let screen_width = self.rect.width() as i32;
168        let screen_height = self.rect.height() as i32;
169
170        let font = font_from_style(fonts, &NORMAL_STYLE, dpi);
171        font.set_size(DISPLAY_FONT_SIZE * 2, dpi);
172        let time_str = format!("{:02}:{:02}", now.hour(), now.minute());
173        let time_plan = font.plan(&time_str, None, None);
174        let time_x = (screen_width - time_plan.width) / 2;
175        let time_cap_height = font.x_heights.1 as i32;
176        let time_y = screen_height * 20 / 100;
177        font.render(fb, scheme[1], &time_plan, Point::new(time_x, time_y));
178
179        let font = font_from_style(fonts, &NORMAL_STYLE, dpi);
180        let normal_line_height = font.x_heights.0 as i32 * 2;
181        let short_month = short_month_name(month);
182        let weekday = weekday_name(now.weekday().num_days_from_monday() as usize);
183        let day_str = format!("{:02}", today);
184        let year_str = year.to_string();
185        let date_str = fl!(
186            "calendar-date-line",
187            day = day_str.as_str(),
188            month = short_month.as_str(),
189            year = year_str.as_str(),
190            weekday = weekday.as_str()
191        );
192        let date_plan = font.plan(&date_str, None, None);
193        let date_x = (screen_width - date_plan.width) / 2;
194        let date_y = time_y + time_cap_height + normal_line_height;
195        font.render(fb, scheme[1], &date_plan, Point::new(date_x, date_y));
196
197        let after_header_y = if let Some(minutes) = self.minutes_until_poweroff.filter(|&m| m > 0) {
198            let duration = Duration::from_secs((minutes * 60).max(0) as u64);
199            let duration_str = humantime::format_duration(duration).to_string();
200            let poweroff_str = fl!("calendar-poweroff", duration = duration_str.as_str());
201            let poweroff_plan = font.plan(&poweroff_str, None, None);
202            let poweroff_x = (screen_width - poweroff_plan.width) / 2;
203            let poweroff_y = date_y + normal_line_height;
204            font.render(
205                fb,
206                scheme[1],
207                &poweroff_plan,
208                Point::new(poweroff_x, poweroff_y),
209            );
210            poweroff_y + normal_line_height
211        } else {
212            date_y + normal_line_height
213        };
214
215        let font = font_from_style(fonts, &NORMAL_STYLE, dpi);
216        font.set_size(FONT_SIZES[1] * 2, dpi);
217        let full_month = month_name(month);
218        let month_plan = font.plan(&full_month, None, None);
219        let month_x = (screen_width - month_plan.width) / 2;
220        // 62% keeps the large clock in the upper portion and the calendar in
221        // the lower portion, but we never let them overlap.
222        let calendar_top = (screen_height * 62 / 100).max(after_header_y + normal_line_height);
223        let month_cap_height = font.x_heights.1 as i32;
224        font.render(
225            fb,
226            month_color,
227            &month_plan,
228            Point::new(month_x, calendar_top),
229        );
230
231        let font = font_from_style(fonts, &NORMAL_STYLE, dpi);
232        let separator_y = calendar_top + month_cap_height + normal_line_height / 2;
233        fb.draw_segment(
234            pt!(self.rect.min.x, separator_y),
235            pt!(self.rect.max.x, separator_y),
236            0.5,
237            0.5,
238            scheme[1],
239        );
240
241        let cell_width = screen_width / 7;
242        let header_y = separator_y + normal_line_height;
243        for i in 0..7usize {
244            let name = weekday_name(i);
245            let plan = font.plan(&name, None, None);
246            let dx = (cell_width - plan.width) / 2 + (i as i32 * cell_width);
247            font.render(fb, scheme[1], &plan, Point::new(dx, header_y));
248        }
249
250        let x_height = font.x_heights.0 as i32;
251        let cell_height = normal_line_height + x_height / 2;
252        let days_start_y = header_y + cell_height;
253
254        let starting_weekday = month_start_weekday(year, month);
255        let current_month_days = days_in_month(year, month);
256        let prev_month_days = days_in_prev_month(year, month);
257
258        for slot in 0..(6 * 7) {
259            let week = slot / 7;
260            let weekday = slot % 7;
261            let cell_x = weekday * cell_width;
262            let cell_y = days_start_y + week * cell_height;
263
264            let (day_num, is_current_month) = if slot < starting_weekday {
265                (prev_month_days - starting_weekday + slot + 1, false)
266            } else {
267                let d = slot - starting_weekday + 1;
268                if d <= current_month_days {
269                    (d, true)
270                } else {
271                    (d - current_month_days, false)
272                }
273            };
274
275            let day_str = format!("{:02}", day_num);
276            let plan = font.plan(&day_str, None, None);
277            let dx = (cell_width - plan.width) / 2 + cell_x;
278
279            if is_current_month && day_num == today {
280                let pad = x_height / 3;
281                // cell_y is the typographic baseline; digits extend upward by
282                // x_height. The highlight covers from above the cap top down
283                // to just below the baseline, then the text renders on top.
284                let highlight = Rectangle::new(
285                    pt!(cell_x + pad, cell_y - x_height - pad),
286                    pt!(cell_x + cell_width - pad, cell_y + pad),
287                );
288                fb.draw_rounded_rectangle(&highlight, &CornerSpec::Uniform(4), today_bg);
289                font.render(fb, scheme[0], &plan, Point::new(dx, cell_y));
290            } else {
291                let color = if is_current_month {
292                    scheme[1]
293                } else {
294                    scheme[2]
295                };
296                font.render(fb, color, &plan, Point::new(dx, cell_y));
297            }
298        }
299    }
300
301    fn rect(&self) -> &Rectangle {
302        &self.rect
303    }
304
305    fn rect_mut(&mut self) -> &mut Rectangle {
306        &mut self.rect
307    }
308
309    fn children(&self) -> &Vec<Box<dyn View>> {
310        &self.children
311    }
312
313    fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
314        &mut self.children
315    }
316
317    fn id(&self) -> Id {
318        self.id
319    }
320}