Skip to main content

frust_material/date_picker/
grid.rs

1// Ported from `material_3_expressive` v1.0.8's day/year grid geometry (MIT,
2// © 2026 Paa Developments;
3// `tmp/material_3_expressive/lib/components/date_pickers/utils/m3e_date_picker_utils.dart`
4// — `dayPickerRowCount`/`calendarDayViewHeight`/`calendarYearViewHeight`/
5// `yearPickerRowCount`/`yearPickerGridNaturalHeight`/`yearPickerGridHeight` —
6// and `components/m3e_day_picker.dart`'s `firstDayOffset`/`cellCount`,
7// retrieved 2026-08-20).
8// Upstream: <https://github.com/paadevelopments/material_3_expressive>
9
10//! Pure grid geometry for the calendar: which cell holds which day, how many
11//! rows a month needs, and how tall each of the two sub-views is.
12//!
13//! Every function here is a total function of plain numbers — no theme, no
14//! widget, no clock — which is what lets the layout rules be pinned directly
15//! rather than inferred from a painted scene.
16
17use 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/// One month's 7-column day grid: how many blank leading cells precede the 1st,
24/// how many days the month has, and therefore how many week rows it occupies.
25///
26/// The reference computes the same three numbers inline in
27/// `M3EDayPicker.build`; they are lifted into a value type here so the grid
28/// layout can be asserted without painting.
29#[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    /// The grid for `month`'s own month (its day component is ignored), with
39    /// the week starting on `first_day_of_week` (`0` Sunday .. `6` Saturday;
40    /// out-of-range values fold with `% 7`).
41    ///
42    /// `leading_blanks` is the reference's `firstDayOffset`, `(weekday -
43    /// firstDayOfWeekIndex) % 7`, expressed over
44    /// [`MaterialDate::weekday_index`]'s already Sunday-folded index.
45    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    /// How many blank cells precede the 1st.
59    pub fn leading_blanks(self) -> usize {
60        self.leading_blanks
61    }
62
63    /// How many days this month has.
64    pub fn days(self) -> usize {
65        self.days
66    }
67
68    /// How many week rows the grid occupies — `ceil((days + blanks) / 7)`,
69    /// always `4..=6` for a real month.
70    pub fn rows(self) -> usize {
71        self.leading_blanks
72            .saturating_add(self.days)
73            .div_ceil(DAYS_PER_WEEK)
74    }
75
76    /// How many cells the grid holds, blanks included (`rows * 7`).
77    pub fn cell_count(self) -> usize {
78        self.rows() * DAYS_PER_WEEK
79    }
80
81    /// The date in cell `index` (row-major from the top-left), or `None` for a
82    /// leading blank or a trailing one past the month's end.
83    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    /// The cell index holding `day` (`1..=`[`Self::days`]), or `None` if the
95    /// month is not that long.
96    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
105/// How many rows a year grid of `count` years occupies (3 per row) — the
106/// reference's `yearPickerRowCount`.
107pub fn year_row_count(count: usize) -> usize {
108    count.div_ceil(YEAR_COLUMN_COUNT)
109}
110
111/// The unbounded height a year grid of `count` years wants — the reference's
112/// `yearPickerGridNaturalHeight` (padding on both ends, one
113/// [`YEAR_ROW_HEIGHT`] per row, [`YEAR_ROW_SPACING`] between rows).
114pub 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
122/// The year grid's on-screen height: its natural height capped at
123/// [`MAX_DAY_PICKER_HEIGHT`] — the reference's `yearPickerGridHeight`. A grid
124/// taller than the cap scrolls inside it.
125pub fn year_grid_height(count: usize) -> f64 {
126    year_grid_natural_height(count).min(MAX_DAY_PICKER_HEIGHT)
127}
128
129/// Whether a year grid of `count` years overflows its capped viewport, i.e.
130/// whether it scrolls at all — the reference's `scrollable` flag.
131pub fn year_grid_scrolls(count: usize) -> bool {
132    year_grid_natural_height(count) > year_grid_height(count)
133}
134
135/// The day sub-view's height for `month` — the reference's
136/// `calendarDayViewHeight`: sub-header, weekday row, one row per week, plus the
137/// grid's own top padding.
138pub 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
143/// The year sub-view's height for a `first..=last` span — the reference's
144/// `calendarYearViewHeight`. `include_sub_header` adds the month/year toggle
145/// band above the grid (the dialog includes it; an inline calendar that already
146/// paints its own toggle does not).
147pub 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
159/// How many years a `first..=last` span covers, inclusive — `0` for a reversed
160/// pair (the degenerate case a caller can hand in; the reference asserts
161/// instead).
162pub 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    /// Sunday-first, the `en_US` `firstDayOfWeekIndex`.
174    const SUNDAY: u32 = 0;
175    /// Monday-first, the `en_GB`/ISO shape — the override an app supplies via
176    /// `DatePickerStrings::first_day_of_week`.
177    const MONDAY: u32 = 1;
178
179    // ---- leading blanks / rows, pinned per month ------------------------
180
181    #[test]
182    fn three_months_with_different_starting_weekdays_lay_out_as_pinned() {
183        // Sunday-first weeks. February 2026 starts on a Sunday, March 2026 on
184        // a Sunday too, so pick three months whose 1st falls on three
185        // different weekdays:
186        //   2026-02-01 Sunday   (blanks 0, 28 days -> 4 rows exactly)
187        //   2026-05-01 Friday   (blanks 5, 31 days -> 6 rows)
188        //   2026-09-01 Tuesday  (blanks 2, 30 days -> 5 rows)
189        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        // 2024-02-01 was a Thursday (blanks 4) with 29 days -> 33 cells -> 5 rows.
211        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        // 2021-02-01 was a Monday (blanks 1) with 28 days -> 29 cells -> 5 rows.
217        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        // 2026-05-01 is a Friday: 5 blanks Sunday-first, 4 Monday-first.
226        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        // 2026-02-01 is a Sunday: 0 blanks Sunday-first, 6 Monday-first (which
230        // pushes it from 4 rows to 5).
231        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    // ---- cell mapping ---------------------------------------------------
268
269    #[test]
270    fn cells_map_to_days_and_back_with_blanks_at_both_ends() {
271        // May 2026: 5 blanks, then 1..=31, then 6 trailing blanks (42 cells).
272        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    // ---- year grid ------------------------------------------------------
317
318    #[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        // One row: 16 + 52 + 16 = 84.
331        assert_eq!(year_grid_natural_height(3), 84.0);
332        // Two rows: 16 + 52 + 8 + 52 + 16 = 144.
333        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        // The cap is 48 * 7 = 336; a natural height past it clips and scrolls.
339        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    // ---- sub-view heights ------------------------------------------------
364
365    #[test]
366    fn the_day_view_height_tracks_the_month_row_count() {
367        // 52 + 24 + 48 * rows + 4.
368        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}