Skip to main content

rosace_widgets/tree/
date_picker.rs

1//! `DatePicker` (D115/Phase 32 Step 1) — a month calendar grid with
2//! year/month navigation. Pure-Rust date math (no `chrono` dependency,
3//! matching the workspace's no-new-deps bias) — just enough calendar
4//! arithmetic (leap years, days-in-month, day-of-week via Zeller's
5//! congruence) to lay out a correct grid; not a general date library.
6
7use std::sync::{Arc, Mutex};
8use rosace_core::types::{Point, Rect, Size};
9use rosace_render::{Color, DrawCommand};
10use super::{LayoutCtx, PaintCtx, Widget, vcenter_text_y, intersect_rect};
11
12/// A plain calendar date — year/month/day, no time-of-day or timezone.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
14pub struct SimpleDate {
15    pub year: i32,
16    /// 1-12.
17    pub month: u8,
18    /// 1-31.
19    pub day: u8,
20}
21
22impl SimpleDate {
23    pub fn new(year: i32, month: u8, day: u8) -> Self {
24        Self { year, month: month.clamp(1, 12), day: day.clamp(1, 31) }
25    }
26
27    pub fn is_leap_year(year: i32) -> bool {
28        (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
29    }
30
31    pub fn days_in_month(year: i32, month: u8) -> u8 {
32        match month {
33            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
34            4 | 6 | 9 | 11 => 30,
35            2 => if Self::is_leap_year(year) { 29 } else { 28 },
36            _ => 30,
37        }
38    }
39
40    /// 0 = Sunday .. 6 = Saturday, via Zeller's congruence (Gregorian).
41    pub fn day_of_week(year: i32, month: u8, day: u8) -> u8 {
42        let (y, m) = if month < 3 { (year - 1, month as i32 + 12) } else { (year, month as i32) };
43        let k = y % 100;
44        let j = y / 100;
45        let h = (day as i32 + (13 * (m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j) % 7;
46        // Zeller's h: 0 = Saturday .. rotate so 0 = Sunday.
47        ((h + 6) % 7).rem_euclid(7) as u8
48    }
49
50    pub fn prev_month(self) -> Self {
51        if self.month == 1 { Self::new(self.year - 1, 12, self.day) } else { Self::new(self.year, self.month - 1, self.day) }
52    }
53
54    pub fn next_month(self) -> Self {
55        if self.month == 12 { Self::new(self.year + 1, 1, self.day) } else { Self::new(self.year, self.month + 1, self.day) }
56    }
57
58    pub fn prev_year(self) -> Self { Self::new(self.year - 1, self.month, self.day) }
59    pub fn next_year(self) -> Self { Self::new(self.year + 1, self.month, self.day) }
60
61    /// Absolute month index (year*12 + month-1) — a monotone integer used to
62    /// animate month-to-month slides and to compare/step months cheaply.
63    pub fn month_ordinal(self) -> i32 { self.year * 12 + (self.month as i32 - 1) }
64
65    /// Inverse of [`Self::month_ordinal`] — day defaults to 1.
66    pub fn from_month_ordinal(ord: i32) -> Self {
67        Self::new(ord.div_euclid(12), (ord.rem_euclid(12) + 1) as u8, 1)
68    }
69
70    fn month_name(month: u8) -> &'static str {
71        const NAMES: [&str; 12] = ["January", "February", "March", "April", "May", "June",
72            "July", "August", "September", "October", "November", "December"];
73        NAMES[(month.clamp(1, 12) - 1) as usize]
74    }
75}
76
77const WEEKDAY_LABELS: [&str; 7] = ["S", "M", "T", "W", "T", "F", "S"];
78
79/// How the calendar selects — a single day or a start→end range.
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
81pub enum SelectionMode { #[default] Single, Range }
82
83/// Which way month-to-month transitions slide: `Horizontal` (Material,
84/// default) slides left/right; `Vertical` slides up/down (iOS-style).
85#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
86pub enum PageAxis { #[default] Horizontal, Vertical }
87
88/// The `SimpleDate` occupying grid `slot` (0..41) of `view`'s 6×7 month page,
89/// plus whether it belongs to `view` (vs a leading/trailing neighbour month).
90fn slot_date(view: SimpleDate, slot: usize) -> (SimpleDate, bool) {
91    let first = SimpleDate::day_of_week(view.year, view.month, 1) as i32;
92    let di = slot as i32 - first + 1; // 1-based day within `view`; may spill either side
93    let days = SimpleDate::days_in_month(view.year, view.month) as i32;
94    if di < 1 {
95        let pm = view.prev_month();
96        let pd = SimpleDate::days_in_month(pm.year, pm.month) as i32;
97        (SimpleDate::new(pm.year, pm.month, (pd + di) as u8), false)
98    } else if di > days {
99        let nm = view.next_month();
100        (SimpleDate::new(nm.year, nm.month, (di - days) as u8), false)
101    } else {
102        (SimpleDate::new(view.year, view.month, di as u8), true)
103    }
104}
105
106/// How many week rows `view`'s month actually occupies (4–6). Rows beyond
107/// this are entirely next-month and are neither drawn nor selectable.
108fn rows_in_month(view: SimpleDate) -> usize {
109    let first = SimpleDate::day_of_week(view.year, view.month, 1) as usize;
110    let days = SimpleDate::days_in_month(view.year, view.month) as usize;
111    (first + days - 1) / 7 + 1
112}
113
114/// Map a content-space point to the day under it within `view`'s grid.
115/// Returns the date, whether it is in `view`, and edge flags (pointer above
116/// the month / below it) that drive cross-month navigation. Rows past the
117/// month's real extent count as `below` — the empty tail is not selectable.
118fn day_at(px: f32, py: f32, body: Rect, view: SimpleDate) -> (SimpleDate, bool, bool, bool) {
119    let rows = rows_in_month(view) as i32;
120    let cw = body.size.width / 7.0;
121    let col = ((px - body.origin.x) / cw).floor() as i32;
122    let row = ((py - body.origin.y) / CELL_H).floor() as i32;
123    let above = row < 0;
124    let below = row >= rows;
125    let c = col.clamp(0, 6);
126    let r = row.clamp(0, rows - 1);
127    let (d, in_cur) = slot_date(view, (r * 7 + c) as usize);
128    (d, in_cur, above, below)
129}
130
131/// Pure range-transition rule (shared by tap dispatch and tests): no range or
132/// a complete one → start fresh; an open start → complete it (ordered).
133fn next_range_for(
134    cur: Option<(SimpleDate, Option<SimpleDate>)>,
135    d: SimpleDate,
136) -> (SimpleDate, Option<SimpleDate>) {
137    match cur {
138        None | Some((_, Some(_))) => (d, None),
139        Some((s, None)) => if d >= s { (s, Some(d)) } else { (d, Some(s)) },
140    }
141}
142
143/// Per-drag scratch state, owned by the active `on_press_at` closure for the
144/// life of one press→release. `view` tracks the month the drag is currently
145/// over so a range can extend across month pages (auto-paged at the edges).
146struct DragState {
147    view: SimpleDate,
148    anchor: Option<SimpleDate>,
149    moved: bool,
150    /// Moves spent inside the top/bottom edge zone since the last auto-page —
151    /// throttles continuous cross-month paging while dragging past the edge.
152    edge_ticks: u32,
153}
154
155/// Auto-page every N drag-moves spent in the edge zone (continuous paging).
156const EDGE_PAGE_EVERY: u32 = 5;
157
158/// A month calendar grid: header with prev/next month nav, weekday labels,
159/// a 7-column day grid. Controlled — the app owns `viewed_month`/`selected`.
160pub struct DatePicker {
161    /// The month currently displayed (day component is ignored for display).
162    viewed_month: SimpleDate,
163    selected: Option<SimpleDate>,
164    today: Option<SimpleDate>,
165    min: Option<SimpleDate>,
166    max: Option<SimpleDate>,
167    mode: SelectionMode,
168    /// (start, optional end) for `Range` mode.
169    range: Option<(SimpleDate, Option<SimpleDate>)>,
170    accent: Option<Color>,
171    range_color: Option<Color>,
172    axis: PageAxis,
173    /// The single selection callback. Fires with `(start, end)`: `Single` mode
174    /// always passes `end = None`; `Range` passes `(start, None)` when the start
175    /// is picked and `(start, Some(end))` once the range completes.
176    on_select: Option<OnSelectFn>,
177    on_month_change: Option<Arc<dyn Fn(SimpleDate) + Send + Sync>>,
178}
179
180/// Fires with `(start, end)`: `Single` mode always passes `end = None`;
181/// `Range` passes `(start, None)` when the start is picked and
182/// `(start, Some(end))` once the range completes.
183type OnSelectFn = Arc<dyn Fn(SimpleDate, Option<SimpleDate>) + Send + Sync>;
184
185const HEADER_H: f32 = 36.0;
186const WEEKDAY_ROW_H: f32 = 24.0;
187const CELL_H: f32 = 36.0;
188const GRID_ROWS: usize = 6;
189
190impl DatePicker {
191    pub fn new(viewed_month: SimpleDate) -> Self {
192        Self {
193            viewed_month,
194            selected: None,
195            today: None,
196            min: None,
197            max: None,
198            mode: SelectionMode::Single,
199            range: None,
200            accent: None,
201            range_color: None,
202            axis: PageAxis::Horizontal,
203            on_select: None,
204            on_month_change: None,
205        }
206    }
207
208    pub fn selected(mut self, d: SimpleDate) -> Self { self.selected = Some(d); self }
209    pub fn today(mut self, d: SimpleDate) -> Self { self.today = Some(d); self }
210    pub fn min_date(mut self, d: SimpleDate) -> Self { self.min = Some(d); self }
211    pub fn max_date(mut self, d: SimpleDate) -> Self { self.max = Some(d); self }
212    pub fn accent(mut self, c: Color) -> Self { self.accent = Some(c); self }
213    /// Selection mode — `Single` (default) or `Range`.
214    pub fn mode(mut self, m: SelectionMode) -> Self { self.mode = m; self }
215    /// The current (start, end) selection for `Range` mode.
216    pub fn range(mut self, start: SimpleDate, end: Option<SimpleDate>) -> Self {
217        self.mode = SelectionMode::Range; self.range = Some((start, end)); self
218    }
219    /// The in-between band fill color (default: a faint accent).
220    pub fn range_color(mut self, c: Color) -> Self { self.range_color = Some(c); self }
221    /// Direction month transitions slide — `Horizontal` (default) or `Vertical`.
222    pub fn axis(mut self, a: PageAxis) -> Self { self.axis = a; self }
223
224    /// The one selection callback, fired right after a day is chosen. It
225    /// receives `(start, end)`:
226    /// - `Single` mode → `(date, None)`.
227    /// - `Range` mode → `(start, None)` when the start is picked, then
228    ///   `(start, Some(end))` once the range completes (drag reports the live
229    ///   `(start, Some(end))` as you sweep).
230    pub fn on_select(mut self, f: impl Fn(SimpleDate, Option<SimpleDate>) + Send + Sync + 'static) -> Self {
231        self.on_select = Some(Arc::new(f));
232        self
233    }
234
235    /// Compute the next range given the current one and a tapped date.
236    #[cfg_attr(not(test), allow(dead_code))]
237    fn next_range(&self, d: SimpleDate) -> (SimpleDate, Option<SimpleDate>) {
238        next_range_for(self.range, d)
239    }
240
241    /// Called with the new viewed month when the prev/next nav is pressed.
242    pub fn on_month_change(mut self, f: impl Fn(SimpleDate) + Send + Sync + 'static) -> Self {
243        self.on_month_change = Some(Arc::new(f));
244        self
245    }
246
247    fn is_disabled(&self, d: SimpleDate) -> bool {
248        self.min.is_some_and(|m| d < m) || self.max.is_some_and(|m| d > m)
249    }
250}
251
252/// Resolved theme colours for a paint pass (borrow of `ctx.theme` must end
253/// before mutable painting — so we snapshot up front).
254struct Pal {
255    bg: Color,
256    on_bg: Color,
257    muted: Color,
258    accent: Color,
259    disabled_fg: Color,
260    band: Color,
261}
262
263fn with_alpha(c: Color, a: f32) -> Color {
264    Color::rgba(c.r, c.g, c.b, (a.clamp(0.0, 1.0) * 255.0).round() as u8)
265}
266
267impl DatePicker {
268    /// Paint one month's 6×7 grid into `area`, clipped to `clip`. Leading and
269    /// trailing days from neighbour months render faded; the range shows as a
270    /// solid per-row band (rounded at the true endpoints) that wraps line to
271    /// line, with accent endpoint discs and a today ring on top.
272    fn paint_month(&self, ctx: &mut PaintCtx, area: Rect, month: SimpleDate, clip: Rect, pal: &Pal) {
273        let mut mc = ctx.child(area);
274        mc.clip_rect = Some(clip);
275        let cw = area.size.width / 7.0;
276        let dot_r = (cw.min(CELL_H) * 0.36).min(16.0);
277        let (r_start, r_end) = match self.range { Some((s, e)) => (Some(s), e), None => (None, None) };
278        let is_range = self.mode == SelectionMode::Range;
279
280        // ── Gooey range band: one full-cell-height rect per row spanning the
281        //    selected columns. Full height means consecutive rows TOUCH, so a
282        //    multi-week range reads as one connected shape; the true start/end
283        //    get a round cap (a disc behind the accent dot), everything else
284        //    is square so week-to-week wraps join seamlessly. ──
285        let rows = rows_in_month(month); // 4–6 real weeks; skip all-next-month rows
286        let band_h = CELL_H; // full row height → consecutive rows touch (connected)
287        if let (true, Some(s), Some(e)) = (is_range, r_start, r_end) {
288            let cap_r = band_h / 2.0;
289            for row in 0..rows {
290                let mut lo: Option<usize> = None;
291                let mut hi: Option<usize> = None;
292                for col in 0..7 {
293                    let (d, _) = slot_date(month, row * 7 + col);
294                    if d >= s && d <= e { lo = lo.or(Some(col)); hi = Some(col); }
295                }
296                if let (Some(lo), Some(hi)) = (lo, hi) {
297                    let (lo_d, _) = slot_date(month, row * 7 + lo);
298                    let (hi_d, _) = slot_date(month, row * 7 + hi);
299                    let lo_is_start = lo_d == s;
300                    let hi_is_end = hi_d == e;
301                    let x0 = area.origin.x + lo as f32 * cw + if lo_is_start { cw / 2.0 } else { 0.0 };
302                    let x1 = area.origin.x + hi as f32 * cw + if hi_is_end { cw / 2.0 } else { cw };
303                    let y = area.origin.y + row as f32 * CELL_H + (CELL_H - band_h) / 2.0;
304                    mc.fill_rect(Rect { origin: Point { x: x0, y }, size: Size { width: (x1 - x0).max(0.0), height: band_h } }, pal.band);
305                    // Rounded caps at the genuine endpoints.
306                    let cy = y + band_h / 2.0;
307                    if lo_is_start { mc.fill_circle(Point { x: area.origin.x + lo as f32 * cw + cw / 2.0, y: cy }, cap_r, pal.band); }
308                    if hi_is_end { mc.fill_circle(Point { x: area.origin.x + hi as f32 * cw + cw / 2.0, y: cy }, cap_r, pal.band); }
309                }
310            }
311        }
312
313        // ── Day cells. ──
314        for slot in 0..rows * 7 {
315            let (col, row) = (slot % 7, slot / 7);
316            let (date, in_cur) = slot_date(month, slot);
317            let x = area.origin.x + col as f32 * cw;
318            let y = area.origin.y + row as f32 * CELL_H;
319            let center = Point { x: x + cw / 2.0, y: y + CELL_H / 2.0 };
320            let disabled = self.is_disabled(date);
321            let is_endpoint = is_range && (r_start == Some(date) || r_end == Some(date));
322            let selected_single = !is_range && in_cur && self.selected == Some(date);
323            let show_circle = is_endpoint || selected_single;
324
325            if show_circle {
326                mc.fill_circle(center, dot_r, pal.accent);
327            } else if in_cur && self.today == Some(date) {
328                mc.stroke_rrect(Rect {
329                    origin: Point { x: center.x - dot_r, y: center.y - dot_r },
330                    size: Size { width: dot_r * 2.0, height: dot_r * 2.0 },
331                }, dot_r, pal.accent, 1.5);
332            }
333
334            let day_str = date.day.to_string();
335            let dw = mc.font.measure_text(&day_str, 13.0);
336            let fg = if show_circle { pal.bg }
337                     else if !in_cur || disabled { pal.disabled_fg }
338                     else { pal.on_bg };
339            mc.draw_text_at(&day_str, Point { x: x + (cw - dw) / 2.0, y: vcenter_text_y(y, CELL_H, mc.font, 13.0) }, fg, 13.0);
340        }
341    }
342
343    /// Paint the year-picker grid (4 columns × 3 rows) for the window starting
344    /// at `base`; selecting a year jumps the view and returns to Days mode.
345    fn paint_years(&self, ctx: &mut PaintCtx, body: Rect, base: i32, pal: &Pal, ctrl: &rosace_scroll::ScrollController) {
346        const COLS: usize = 4;
347        const ROWS: usize = 3;
348        let cw = body.size.width / COLS as f32;
349        let ch = body.size.height / ROWS as f32;
350        for i in 0..COLS * ROWS {
351            let year = base + i as i32;
352            let (col, row) = (i % COLS, i / COLS);
353            let cell = Rect {
354                origin: Point { x: body.origin.x + col as f32 * cw, y: body.origin.y + row as f32 * ch },
355                size: Size { width: cw, height: ch },
356            };
357            let mut yc = ctx.child(cell);
358            yc.hoverable();
359            let (hov, prs) = (yc.hovered(), yc.pressed());
360            let selected = year == self.viewed_month.year;
361            let center = Point { x: cell.origin.x + cw / 2.0, y: cell.origin.y + ch / 2.0 };
362            let pill = Rect { origin: Point { x: center.x - cw * 0.38, y: center.y - 16.0 }, size: Size { width: cw * 0.76, height: 32.0 } };
363            if selected {
364                yc.fill_rrect(pill, 16.0, pal.accent);
365            } else if hov || prs {
366                yc.fill_rrect(pill, 16.0, with_alpha(pal.on_bg, if prs { 0.14 } else { 0.08 }));
367            }
368            let label = year.to_string();
369            let lw = yc.font.measure_text(&label, 15.0);
370            let fg = if selected { pal.bg } else { pal.on_bg };
371            yc.draw_text_at(&label, Point { x: center.x - lw / 2.0, y: vcenter_text_y(cell.origin.y, ch, yc.font, 15.0) }, fg, 15.0);
372
373            let ctrl = ctrl.clone();
374            let month = self.viewed_month.month;
375            let day = self.viewed_month.day;
376            match &self.on_month_change {
377                Some(f) => {
378                    let f = f.clone();
379                    yc.register_hit(Arc::new(move || {
380                        f(SimpleDate::new(year, month, day));
381                        let o = ctrl.offset.get();
382                        ctrl.offset.set([0.0, o[1]]); // back to Days mode
383                    }));
384                }
385                None => yc.register_hit(Arc::new(|| {})),
386            }
387        }
388    }
389}
390
391impl Widget for DatePicker {
392    fn layout(&self, ctx: &LayoutCtx) -> Size {
393        // Fill the available width (so it spans a phone screen) with a min for
394        // legible cells and a generous max so an unbounded parent can't blow it
395        // up. 320 was too narrow on modern phones — it left a right-side gap.
396        let width = super::avail_w(ctx.constraints).clamp(7.0 * CELL_H, 500.0);
397        let height = HEADER_H + WEEKDAY_ROW_H + GRID_ROWS as f32 * CELL_H;
398        Size { width, height }
399    }
400
401    fn paint(&self, ctx: &mut PaintCtx) {
402        let pal = {
403            let t = &ctx.theme.colors;
404            let accent = self.accent.unwrap_or_else(|| ctx.tc(t.primary));
405            Pal {
406                bg: ctx.tc(t.surface),
407                on_bg: ctx.tc(t.on_surface),
408                muted: ctx.tc(t.outline),
409                accent,
410                disabled_fg: with_alpha(ctx.tc(t.on_surface), 0.35),
411                band: self.range_color.unwrap_or_else(|| with_alpha(accent, 0.32)),
412            }
413        };
414        let r = ctx.rect;
415        let cell_w = r.size.width / 7.0;
416
417        // View mode + year-window persist in this node's scroll controller
418        // (offset[0] = 0 Days / 1 Years, offset[1] = year-grid base) — the
419        // Carousel-style "spare slot" pattern, no app-owned atom required.
420        let ctrl = ctx.scroll_controller();
421        let years_mode = ctrl.offset.get()[0] > 0.5;
422        let year_base = {
423            let stored = ctrl.offset.get()[1] as i32;
424            if stored == 0 { self.viewed_month.year - self.viewed_month.year.rem_euclid(12) } else { stored }
425        };
426
427        // ── Header: ‹  Month Year (tap → years)  › ──────────────────────────
428        let header_rect = Rect { origin: r.origin, size: Size { width: r.size.width, height: HEADER_H } };
429        let nav_w = HEADER_H;
430        let month = self.viewed_month;
431        let label = if years_mode {
432            format!("{} \u{2013} {}", year_base, year_base + 11)
433        } else {
434            format!("{} {}", SimpleDate::month_name(month.month), month.year)
435        };
436        // Tappable label (centre) toggles Days ⇄ Years.
437        let label_rect = Rect {
438            origin: Point { x: header_rect.origin.x + nav_w, y: header_rect.origin.y },
439            size: Size { width: (r.size.width - 2.0 * nav_w).max(0.0), height: HEADER_H },
440        };
441        {
442            let mut hdr = ctx.child(label_rect);
443            hdr.hoverable();
444            let text_w = hdr.font.measure_text(&label, 15.0);
445            hdr.draw_text_at(&label, Point {
446                x: label_rect.origin.x + (label_rect.size.width - text_w) / 2.0,
447                y: vcenter_text_y(label_rect.origin.y, HEADER_H, hdr.font, 15.0),
448            }, pal.on_bg, 15.0);
449            let ctrl_t = ctrl.clone();
450            hdr.register_hit(Arc::new(move || {
451                let o = ctrl_t.offset.get();
452                ctrl_t.offset.set([if o[0] > 0.5 { 0.0 } else { 1.0 }, o[1]]);
453            }));
454        }
455
456        // Chevrons — page the month (Days) or the year window (Years).
457        let prev_rect = Rect { origin: header_rect.origin, size: Size { width: nav_w, height: HEADER_H } };
458        let next_rect = Rect {
459            origin: Point { x: header_rect.origin.x + r.size.width - nav_w, y: header_rect.origin.y },
460            size: Size { width: nav_w, height: HEADER_H },
461        };
462        for (rect, kind, back) in [
463            (prev_rect, super::IconKind::ChevronLeft, true),
464            (next_rect, super::IconKind::ChevronRight, false),
465        ] {
466            let mut btn = ctx.child(rect);
467            btn.hoverable();
468            let (hov, prs) = (btn.hovered(), btn.pressed());
469            let c = Point { x: rect.origin.x + nav_w / 2.0, y: rect.origin.y + HEADER_H / 2.0 };
470            if hov || prs {
471                btn.fill_circle(c, 15.0, with_alpha(pal.on_bg, if prs { 0.14 } else { 0.08 }));
472            }
473            let isz = 22.0;
474            let ir = Rect { origin: Point { x: c.x - isz / 2.0, y: c.y - isz / 2.0 }, size: Size { width: isz, height: isz } };
475            super::Icon::new(kind).size(isz).color(pal.on_bg).paint(&mut btn.child(ir));
476            if years_mode {
477                let ctrl_y = ctrl.clone();
478                let target_base = year_base + if back { -12 } else { 12 };
479                btn.register_hit(Arc::new(move || {
480                    let o = ctrl_y.offset.get();
481                    ctrl_y.offset.set([o[0], target_base as f32]);
482                }));
483            } else {
484                match &self.on_month_change {
485                    Some(f) => { let f = f.clone(); let next = if back { month.prev_month() } else { month.next_month() }; btn.register_hit(Arc::new(move || f(next))); }
486                    None => btn.register_hit(Arc::new(|| {})),
487                }
488            }
489        }
490
491        // Year picker fills the body and returns early.
492        if years_mode {
493            let body = Rect {
494                origin: Point { x: r.origin.x, y: r.origin.y + HEADER_H },
495                size: Size { width: r.size.width, height: r.size.height - HEADER_H },
496            };
497            // Persist the base so chevron paging is stable across frames.
498            if ctrl.offset.get()[1] as i32 == 0 { ctrl.offset.set([1.0, year_base as f32]); }
499            self.paint_years(ctx, body, year_base, &pal, &ctrl);
500            ctx.semantics(super::Semantics::new(rosace_core::Role::Unknown).label(format!("Year picker, {label}")));
501            return;
502        }
503
504        // ── Weekday labels. ──
505        let weekday_y = r.origin.y + HEADER_H;
506        for (i, wl) in WEEKDAY_LABELS.iter().enumerate() {
507            let w = ctx.font.measure_text(wl, 12.0);
508            ctx.draw_text_at(wl, Point {
509                x: r.origin.x + i as f32 * cell_w + (cell_w - w) / 2.0,
510                y: vcenter_text_y(weekday_y, WEEKDAY_ROW_H, ctx.font, 12.0),
511            }, pal.muted, 12.0);
512        }
513
514        // ── Animated month slide: draw the month(s) that overlap the eased
515        //    ordinal, offset horizontally, clipped to the body. ──
516        let grid_top = weekday_y + WEEKDAY_ROW_H;
517        let body = Rect {
518            origin: Point { x: r.origin.x, y: grid_top },
519            size: Size { width: r.size.width, height: GRID_ROWS as f32 * CELL_H },
520        };
521        let target_ord = month.month_ordinal() as f32;
522        let eased = ctx.animate_to(target_ord, 0.0);
523        let vertical = self.axis == PageAxis::Vertical;
524        // Actively sliding only when the eased position hasn't reached the
525        // target (large year jumps snap: >1.5 months). At rest exactly one
526        // month is in view and it already fits, so we skip the clip entirely —
527        // a stray PushClip inside a GPU-composited scroll layer is applied in
528        // the wrong coordinate space and would crop the calendar. The clip is
529        // only needed to hide the incoming/outgoing month during a transition.
530        let sliding = (eased - target_ord).abs() > 0.001 && (eased - target_ord).abs() <= 1.5;
531        let slide = if sliding { eased } else { target_ord };
532        let lo = slide.floor();
533        let clip = ctx.clip_rect.and_then(|p| intersect_rect(p, body)).unwrap_or(body);
534        if sliding { ctx.record(DrawCommand::PushClip { rect: body }); }
535        for ord in [lo as i32, lo as i32 + 1] {
536            let off = (ord as f32 - slide) * if vertical { body.size.height } else { body.size.width };
537            let (x, y) = if vertical { (body.origin.x, body.origin.y + off) } else { (body.origin.x + off, body.origin.y) };
538            // Cull the page once it is fully off the body on the slide axis.
539            let (lead, span, size) = if vertical { (y, body.origin.y, body.size.height) } else { (x, body.origin.x, body.size.width) };
540            if lead + size <= span || lead >= span + size { continue; }
541            let area = Rect { origin: Point { x, y }, size: body.size };
542            self.paint_month(ctx, area, SimpleDate::from_month_ordinal(ord), clip, &pal);
543        }
544        if sliding { ctx.record(DrawCommand::PopClip); }
545
546        // ── Gesture owner: one stable body-level positional handler. Its
547        //    coordinates arrive already remapped into content space (unlike
548        //    `current_pointer()`), so drag-select is correct inside scroll
549        //    views; the closure owns the whole press→release for one gesture. ──
550        {
551            let g = ctx.child(body);
552            let is_range = self.mode == SelectionMode::Range;
553            let range0 = self.range;
554            let (min, max) = (self.min, self.max);
555            let is_disabled = move |d: SimpleDate| min.is_some_and(|m| d < m) || max.is_some_and(|m| d > m);
556            let on_select = self.on_select.clone();
557            let on_month = self.on_month_change.clone();
558            let start_view = month;
559            let st = Arc::new(Mutex::new(DragState { view: start_view, anchor: None, moved: false, edge_ticks: 0 }));
560            g.on_press_at(move |px, py| {
561                let mut s = st.lock().unwrap();
562                let view = s.view;
563                let (date, in_cur, above, below) = day_at(px, py, body, view);
564                if s.anchor.is_none() {
565                    // Press-down. Establish the anchor and apply tap semantics
566                    // (a drag overrides this on the next move).
567                    s.anchor = Some(date);
568                    // A tap past the month's real rows just navigates there.
569                    if above || below {
570                        let nm = if above { view.prev_month() } else { view.next_month() };
571                        s.view = nm;
572                        if let Some(m) = &on_month { m(nm); }
573                        return;
574                    }
575                    // A leading/trailing day inside the grid moves the view to
576                    // its month, then selects it there (don't select a stray
577                    // neighbour-month day while showing this month).
578                    if !in_cur {
579                        let nm = SimpleDate::new(date.year, date.month, 1);
580                        s.view = nm;
581                        if let Some(m) = &on_month { m(nm); }
582                    }
583                    if is_disabled(date) { return; }
584                    if let Some(f) = &on_select {
585                        if is_range {
586                            let (ns, ne) = next_range_for(range0, date);
587                            f(ns, ne);
588                        } else {
589                            f(date, None);
590                        }
591                    }
592                    return;
593                }
594                let anchor = s.anchor.unwrap();
595                if is_range && date != anchor {
596                    if let Some(f) = &on_select {
597                        if !is_disabled(date) {
598                            s.moved = true;
599                            let (a, b) = if date >= anchor { (anchor, date) } else { (date, anchor) };
600                            f(a, Some(b));
601                        }
602                    }
603                }
604                // Cross-month: while dragging past the top/bottom edge, page
605                // repeatedly (throttled) so a range can sweep across several
606                // months — not just the immediate neighbour.
607                let edge = above || below;
608                if is_range && edge {
609                    s.edge_ticks += 1;
610                    if s.edge_ticks >= EDGE_PAGE_EVERY {
611                        s.edge_ticks = 0;
612                        let nm = if above { view.prev_month() } else { view.next_month() };
613                        s.view = nm;
614                        if let Some(m) = &on_month { m(nm); }
615                    }
616                } else {
617                    s.edge_ticks = 0;
618                }
619            });
620        }
621
622        ctx.semantics(super::Semantics::new(rosace_core::Role::Unknown)
623            .label(format!("Date picker, {label}")));
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use rosace_layout::Constraints;
631
632    #[test]
633    #[ignore] // DATE_PNG=/path cargo test -p rosace-widgets date_range_showcase -- --ignored --nocapture
634    fn date_range_showcase() {
635        use super::super::app::WidgetApp;
636        let out = std::env::var("DATE_PNG").unwrap_or_else(|_| "date.png".to_string());
637        let mut theme = rosace_theme::built_in::dark_theme();
638        theme.animation.enabled = false;
639        let (w, h) = (320u32, 300u32);
640        // A multi-row range → solid wrapping band + faded leading/trailing days.
641        let range = DatePicker::new(SimpleDate::new(2026, 7, 1))
642            .today(SimpleDate::new(2026, 7, 24))
643            .range(SimpleDate::new(2026, 7, 8), Some(SimpleDate::new(2026, 7, 19)));
644        std::fs::write(&out, WidgetApp::new(w, h).theme(theme.clone()).render_png(&range)).unwrap();
645        println!("wrote {out}");
646    }
647
648    #[test]
649    fn slot_date_fills_leading_and_trailing_from_neighbour_months() {
650        // July 2026 starts on a Wednesday (day_of_week == 3), so slots 0..2
651        // are the tail of June, slot 3 is Jul 1, and the grid overruns into
652        // August after Jul 31.
653        let view = SimpleDate::new(2026, 7, 1);
654        assert_eq!(SimpleDate::day_of_week(2026, 7, 1), 3);
655        assert_eq!(slot_date(view, 0), (SimpleDate::new(2026, 6, 28), false));
656        assert_eq!(slot_date(view, 2), (SimpleDate::new(2026, 6, 30), false));
657        assert_eq!(slot_date(view, 3), (SimpleDate::new(2026, 7, 1), true));
658        assert_eq!(slot_date(view, 33), (SimpleDate::new(2026, 7, 31), true));
659        assert_eq!(slot_date(view, 34), (SimpleDate::new(2026, 8, 1), false));
660    }
661
662    #[test]
663    fn day_at_maps_point_to_cell_and_flags_edges() {
664        let body = Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 280.0, height: 216.0 } };
665        let view = SimpleDate::new(2026, 7, 1);
666        let cw = 280.0 / 7.0;
667        // Row 0, col 3 (centre) → Jul 1.
668        let (d, in_cur, above, below) = day_at(cw * 3.5, CELL_H * 0.5, body, view);
669        assert_eq!((d, in_cur, above, below), (SimpleDate::new(2026, 7, 1), true, false, false));
670        // Above the grid → prev-month edge flag.
671        let (_, _, above, _) = day_at(cw * 3.5, -5.0, body, view);
672        assert!(above);
673        // Below the last row → next-month edge flag.
674        let (_, _, _, below) = day_at(cw * 3.5, CELL_H * (GRID_ROWS as f32) + 5.0, body, view);
675        assert!(below);
676    }
677
678    #[test]
679    #[ignore] // STACK_PNG=/path cargo test -p rosace-widgets stacked_pickers -- --ignored --nocapture
680    fn stacked_pickers() {
681        use super::super::app::WidgetApp;
682        use super::super::column::Column;
683        let out = std::env::var("STACK_PNG").unwrap_or_else(|_| "stack.png".to_string());
684        let mut theme = rosace_theme::built_in::dark_theme();
685        theme.animation.enabled = false;
686        let col = Column::new()
687            .spacing(10.0)
688            .child(DatePicker::new(SimpleDate::new(2026, 7, 1)).axis(PageAxis::Vertical)
689                .mode(SelectionMode::Range).range(SimpleDate::new(2026, 7, 8), Some(SimpleDate::new(2026, 7, 16))))
690            .child(DatePicker::new(SimpleDate::new(2026, 7, 1))
691                .today(SimpleDate::new(2026, 7, 28))
692                .min_date(SimpleDate::new(2026, 7, 6)).max_date(SimpleDate::new(2026, 7, 24)));
693        std::fs::write(&out, WidgetApp::new(340, 620).theme(theme).render_png(&col)).unwrap();
694        println!("wrote {out}");
695    }
696
697    #[test]
698    fn rows_in_month_omits_all_next_month_weeks() {
699        // July 2026 starts Wed and has 31 days → 5 rows (the 6th would be all
700        // August, so it must not render).
701        assert_eq!(rows_in_month(SimpleDate::new(2026, 7, 1)), 5);
702        // Feb 2026 starts Sunday, 28 days → exactly 4 rows.
703        assert_eq!(SimpleDate::day_of_week(2026, 2, 1), 0);
704        assert_eq!(rows_in_month(SimpleDate::new(2026, 2, 1)), 4);
705        // A month that genuinely needs 6 rows: Aug 2026 starts Saturday, 31 days.
706        assert_eq!(SimpleDate::day_of_week(2026, 8, 1), 6);
707        assert_eq!(rows_in_month(SimpleDate::new(2026, 8, 1)), 6);
708    }
709
710    #[test]
711    fn month_ordinal_roundtrips() {
712        let d = SimpleDate::new(2026, 7, 15);
713        assert_eq!(SimpleDate::from_month_ordinal(d.month_ordinal()), SimpleDate::new(2026, 7, 1));
714        assert_eq!(SimpleDate::new(2026, 12, 1).month_ordinal() + 1,
715                   SimpleDate::new(2027, 1, 1).month_ordinal());
716    }
717
718    #[test]
719    fn next_range_starts_completes_and_restarts() {
720        let d = |day| SimpleDate::new(2026, 7, day);
721        let base = DatePicker::new(d(1));
722        assert_eq!(base.next_range(d(5)), (d(5), None), "empty → start");
723        let started = DatePicker::new(d(1)).range(d(5), None);
724        assert_eq!(started.next_range(d(9)), (d(5), Some(d(9))), "start+later → complete");
725        assert_eq!(started.next_range(d(2)), (d(2), Some(d(5))), "start+earlier → ordered");
726        let complete = DatePicker::new(d(1)).range(d(5), Some(d(9)));
727        assert_eq!(complete.next_range(d(12)), (d(12), None), "complete → restart");
728    }
729
730    #[test]
731    fn leap_year_math_is_correct() {
732        assert!(SimpleDate::is_leap_year(2024));
733        assert!(!SimpleDate::is_leap_year(2023));
734        assert!(!SimpleDate::is_leap_year(1900), "divisible by 100 but not 400");
735        assert!(SimpleDate::is_leap_year(2000), "divisible by 400");
736    }
737
738    #[test]
739    fn days_in_month_matches_calendar() {
740        assert_eq!(SimpleDate::days_in_month(2024, 2), 29);
741        assert_eq!(SimpleDate::days_in_month(2023, 2), 28);
742        assert_eq!(SimpleDate::days_in_month(2024, 4), 30);
743        assert_eq!(SimpleDate::days_in_month(2024, 1), 31);
744    }
745
746    #[test]
747    fn day_of_week_matches_known_dates() {
748        // 2024-01-01 was a Monday.
749        assert_eq!(SimpleDate::day_of_week(2024, 1, 1), 1);
750        // 2000-01-01 was a Saturday.
751        assert_eq!(SimpleDate::day_of_week(2000, 1, 1), 6);
752        // 2024-07-17 (today, this session) was a Wednesday.
753        assert_eq!(SimpleDate::day_of_week(2024, 7, 17), 3);
754    }
755
756    #[test]
757    fn month_navigation_wraps_year() {
758        let d = SimpleDate::new(2024, 1, 15);
759        assert_eq!(d.prev_month(), SimpleDate::new(2023, 12, 15));
760        let d = SimpleDate::new(2024, 12, 15);
761        assert_eq!(d.next_month(), SimpleDate::new(2025, 1, 15));
762    }
763
764    #[test]
765    fn layout_reports_expected_height() {
766        let font = rosace_render::FontCache::embedded();
767        let theme = rosace_theme::built_in::dark_theme();
768        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
769        let size = DatePicker::new(SimpleDate::new(2024, 7, 1)).layout(&ctx);
770        assert_eq!(size.height, HEADER_H + WEEKDAY_ROW_H + GRID_ROWS as f32 * CELL_H);
771    }
772
773    #[test]
774    fn min_max_range_disables_out_of_range_dates() {
775        let dp = DatePicker::new(SimpleDate::new(2024, 7, 1))
776            .min_date(SimpleDate::new(2024, 7, 10))
777            .max_date(SimpleDate::new(2024, 7, 20));
778        assert!(dp.is_disabled(SimpleDate::new(2024, 7, 5)));
779        assert!(dp.is_disabled(SimpleDate::new(2024, 7, 25)));
780        assert!(!dp.is_disabled(SimpleDate::new(2024, 7, 15)));
781    }
782}