1use super::date::{MaterialDate, days_in_month};
18use super::{
19 DAY_GRID_TOP_PADDING, DAY_ROW_HEIGHT, DAYS_PER_WEEK, MAX_DAY_PICKER_HEIGHT, SUB_HEADER_HEIGHT,
20 WEEKDAY_ROW_HEIGHT, YEAR_COLUMN_COUNT, YEAR_GRID_PADDING, YEAR_ROW_HEIGHT, YEAR_ROW_SPACING,
21};
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub struct MonthGrid {
31 year: i32,
32 month: u32,
33 leading_blanks: usize,
34 days: usize,
35}
36
37impl MonthGrid {
38 pub fn new(month: MaterialDate, first_day_of_week: u32) -> Self {
46 let first = month.first_of_month();
47 let start = first.weekday_index();
48 let offset = first_day_of_week % DAYS_PER_WEEK as u32;
49 Self {
50 year: first.year(),
51 month: first.month(),
52 leading_blanks: ((start + DAYS_PER_WEEK as u32 - offset) % DAYS_PER_WEEK as u32)
53 as usize,
54 days: days_in_month(first.year(), first.month()) as usize,
55 }
56 }
57
58 pub fn leading_blanks(self) -> usize {
60 self.leading_blanks
61 }
62
63 pub fn days(self) -> usize {
65 self.days
66 }
67
68 pub fn rows(self) -> usize {
71 self.leading_blanks
72 .saturating_add(self.days)
73 .div_ceil(DAYS_PER_WEEK)
74 }
75
76 pub fn cell_count(self) -> usize {
78 self.rows() * DAYS_PER_WEEK
79 }
80
81 pub fn date_at(self, index: usize) -> Option<MaterialDate> {
84 if index < self.leading_blanks {
85 return None;
86 }
87 let day = index - self.leading_blanks + 1;
88 if day > self.days {
89 return None;
90 }
91 Some(MaterialDate::new(self.year, self.month, day as u32))
92 }
93
94 pub fn index_of(self, day: u32) -> Option<usize> {
97 let day = day as usize;
98 if day < 1 || day > self.days {
99 return None;
100 }
101 Some(self.leading_blanks + day - 1)
102 }
103}
104
105pub fn year_row_count(count: usize) -> usize {
108 count.div_ceil(YEAR_COLUMN_COUNT)
109}
110
111pub fn year_grid_natural_height(count: usize) -> f64 {
115 if count == 0 {
116 return 0.0;
117 }
118 let rows = year_row_count(count) as f64;
119 YEAR_GRID_PADDING * 2.0 + rows * YEAR_ROW_HEIGHT + (rows - 1.0) * YEAR_ROW_SPACING
120}
121
122pub fn year_grid_height(count: usize) -> f64 {
126 year_grid_natural_height(count).min(MAX_DAY_PICKER_HEIGHT)
127}
128
129pub fn year_grid_scrolls(count: usize) -> bool {
132 year_grid_natural_height(count) > year_grid_height(count)
133}
134
135pub fn calendar_day_view_height(month: MaterialDate, first_day_of_week: u32) -> f64 {
139 let rows = MonthGrid::new(month, first_day_of_week).rows() as f64;
140 SUB_HEADER_HEIGHT + WEEKDAY_ROW_HEIGHT + DAY_ROW_HEIGHT * rows + DAY_GRID_TOP_PADDING
141}
142
143pub fn calendar_year_view_height(
148 first: MaterialDate,
149 last: MaterialDate,
150 include_sub_header: bool,
151) -> f64 {
152 let mut height = year_grid_height(year_span(first, last));
153 if include_sub_header {
154 height += SUB_HEADER_HEIGHT;
155 }
156 height
157}
158
159pub fn year_span(first: MaterialDate, last: MaterialDate) -> usize {
163 if last.year() < first.year() {
164 return 0;
165 }
166 (last.year() - first.year() + 1) as usize
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 const SUNDAY: u32 = 0;
175 const MONDAY: u32 = 1;
178
179 #[test]
182 fn three_months_with_different_starting_weekdays_lay_out_as_pinned() {
183 let feb = MonthGrid::new(MaterialDate::new(2026, 2, 14), SUNDAY);
190 assert_eq!(feb.leading_blanks(), 0);
191 assert_eq!(feb.days(), 28);
192 assert_eq!(feb.rows(), 4, "a 28-day month starting on the first column");
193 assert_eq!(feb.cell_count(), 28);
194
195 let may = MonthGrid::new(MaterialDate::new(2026, 5, 1), SUNDAY);
196 assert_eq!(may.leading_blanks(), 5);
197 assert_eq!(may.days(), 31);
198 assert_eq!(may.rows(), 6);
199 assert_eq!(may.cell_count(), 42);
200
201 let sep = MonthGrid::new(MaterialDate::new(2026, 9, 30), SUNDAY);
202 assert_eq!(sep.leading_blanks(), 2);
203 assert_eq!(sep.days(), 30);
204 assert_eq!(sep.rows(), 5);
205 assert_eq!(sep.cell_count(), 35);
206 }
207
208 #[test]
209 fn a_leap_february_gains_a_row_when_it_starts_late_enough() {
210 let leap = MonthGrid::new(MaterialDate::new(2024, 2, 1), SUNDAY);
212 assert_eq!(leap.leading_blanks(), 4);
213 assert_eq!(leap.days(), 29);
214 assert_eq!(leap.rows(), 5);
215
216 let common = MonthGrid::new(MaterialDate::new(2021, 2, 1), SUNDAY);
218 assert_eq!(common.leading_blanks(), 1);
219 assert_eq!(common.days(), 28);
220 assert_eq!(common.rows(), 5);
221 }
222
223 #[test]
224 fn the_first_day_of_week_rotates_the_leading_blanks() {
225 let may = MaterialDate::new(2026, 5, 1);
227 assert_eq!(MonthGrid::new(may, SUNDAY).leading_blanks(), 5);
228 assert_eq!(MonthGrid::new(may, MONDAY).leading_blanks(), 4);
229 let feb = MaterialDate::new(2026, 2, 1);
232 assert_eq!(MonthGrid::new(feb, SUNDAY).leading_blanks(), 0);
233 assert_eq!(MonthGrid::new(feb, SUNDAY).rows(), 4);
234 assert_eq!(MonthGrid::new(feb, MONDAY).leading_blanks(), 6);
235 assert_eq!(MonthGrid::new(feb, MONDAY).rows(), 5);
236 }
237
238 #[test]
239 fn an_out_of_range_first_day_of_week_folds_rather_than_panicking() {
240 let may = MaterialDate::new(2026, 5, 1);
241 assert_eq!(
242 MonthGrid::new(may, 7).leading_blanks(),
243 MonthGrid::new(may, SUNDAY).leading_blanks()
244 );
245 assert_eq!(
246 MonthGrid::new(may, 15).leading_blanks(),
247 MonthGrid::new(may, MONDAY).leading_blanks()
248 );
249 }
250
251 #[test]
252 fn every_month_of_a_year_needs_between_four_and_six_rows() {
253 for year in [1900, 2000, 2024, 2026] {
254 for month in 1..=12 {
255 for start in 0..7 {
256 let grid = MonthGrid::new(MaterialDate::new(year, month, 1), start);
257 let rows = grid.rows();
258 assert!(
259 (4..=6).contains(&rows),
260 "{year}-{month:02} at start {start} wanted {rows} rows"
261 );
262 }
263 }
264 }
265 }
266
267 #[test]
270 fn cells_map_to_days_and_back_with_blanks_at_both_ends() {
271 let grid = MonthGrid::new(MaterialDate::new(2026, 5, 1), SUNDAY);
273 for i in 0..5 {
274 assert_eq!(grid.date_at(i), None, "cell {i} is a leading blank");
275 }
276 assert_eq!(grid.date_at(5), Some(MaterialDate::new(2026, 5, 1)));
277 assert_eq!(grid.date_at(35), Some(MaterialDate::new(2026, 5, 31)));
278 for i in 36..grid.cell_count() {
279 assert_eq!(grid.date_at(i), None, "cell {i} is a trailing blank");
280 }
281 assert_eq!(grid.date_at(grid.cell_count()), None, "past the last cell");
282
283 assert_eq!(grid.index_of(1), Some(5));
284 assert_eq!(grid.index_of(31), Some(35));
285 assert_eq!(grid.index_of(32), None);
286 assert_eq!(grid.index_of(0), None);
287 }
288
289 #[test]
290 fn date_at_and_index_of_are_inverses_for_every_real_day() {
291 for month in 1..=12 {
292 let grid = MonthGrid::new(MaterialDate::new(2026, month, 1), SUNDAY);
293 for day in 1..=grid.days() as u32 {
294 let index = grid.index_of(day).expect("a real day has a cell");
295 assert_eq!(
296 grid.date_at(index),
297 Some(MaterialDate::new(2026, month, day))
298 );
299 }
300 }
301 }
302
303 #[test]
304 fn every_row_holds_exactly_seven_cells_and_the_days_are_contiguous() {
305 let grid = MonthGrid::new(MaterialDate::new(2026, 9, 1), SUNDAY);
306 let mut seen = Vec::new();
307 for i in 0..grid.cell_count() {
308 if let Some(date) = grid.date_at(i) {
309 seen.push(date.day());
310 }
311 }
312 assert_eq!(seen, (1..=30).collect::<Vec<u32>>());
313 assert_eq!(grid.cell_count() % DAYS_PER_WEEK, 0);
314 }
315
316 #[test]
319 fn the_year_grid_packs_three_per_row() {
320 assert_eq!(year_row_count(0), 0);
321 assert_eq!(year_row_count(1), 1);
322 assert_eq!(year_row_count(3), 1);
323 assert_eq!(year_row_count(4), 2);
324 assert_eq!(year_row_count(21), 7);
325 assert_eq!(year_row_count(22), 8);
326 }
327
328 #[test]
329 fn the_year_grid_height_is_capped_and_reports_when_it_scrolls() {
330 assert_eq!(year_grid_natural_height(3), 84.0);
332 assert_eq!(year_grid_natural_height(6), 144.0);
334 assert_eq!(year_grid_natural_height(0), 0.0);
335 assert_eq!(year_grid_height(3), 84.0);
336 assert!(!year_grid_scrolls(3));
337
338 assert!(year_grid_natural_height(30) > MAX_DAY_PICKER_HEIGHT);
340 assert_eq!(year_grid_height(30), MAX_DAY_PICKER_HEIGHT);
341 assert!(year_grid_scrolls(30));
342 }
343
344 #[test]
345 fn the_year_span_is_inclusive_and_a_reversed_pair_is_empty() {
346 assert_eq!(
347 year_span(
348 MaterialDate::new(2026, 1, 1),
349 MaterialDate::new(2026, 12, 31)
350 ),
351 1
352 );
353 assert_eq!(
354 year_span(MaterialDate::new(2020, 6, 1), MaterialDate::new(2030, 6, 1)),
355 11
356 );
357 assert_eq!(
358 year_span(MaterialDate::new(2030, 1, 1), MaterialDate::new(2020, 1, 1)),
359 0
360 );
361 }
362
363 #[test]
366 fn the_day_view_height_tracks_the_month_row_count() {
367 let four_rows = calendar_day_view_height(MaterialDate::new(2026, 2, 1), SUNDAY);
369 let five_rows = calendar_day_view_height(MaterialDate::new(2026, 9, 1), SUNDAY);
370 let six_rows = calendar_day_view_height(MaterialDate::new(2026, 5, 1), SUNDAY);
371 assert_eq!(four_rows, 52.0 + 24.0 + 48.0 * 4.0 + 4.0);
372 assert_eq!(five_rows, 52.0 + 24.0 + 48.0 * 5.0 + 4.0);
373 assert_eq!(six_rows, 52.0 + 24.0 + 48.0 * 6.0 + 4.0);
374 assert!(four_rows < five_rows && five_rows < six_rows);
375 }
376
377 #[test]
378 fn the_year_view_height_optionally_carries_the_sub_header() {
379 let first = MaterialDate::new(2020, 1, 1);
380 let last = MaterialDate::new(2030, 12, 31);
381 let bare = calendar_year_view_height(first, last, false);
382 let with_header = calendar_year_view_height(first, last, true);
383 assert_eq!(bare, year_grid_height(11));
384 assert_eq!(with_header - bare, SUB_HEADER_HEIGHT);
385 }
386}