1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
14pub struct SimpleDate {
15 pub year: i32,
16 pub month: u8,
18 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 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 ((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 pub fn month_ordinal(self) -> i32 { self.year * 12 + (self.month as i32 - 1) }
64
65 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
81pub enum SelectionMode { #[default] Single, Range }
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
86pub enum PageAxis { #[default] Horizontal, Vertical }
87
88fn 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; 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
106fn 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
114fn 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
131fn 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
143struct DragState {
147 view: SimpleDate,
148 anchor: Option<SimpleDate>,
149 moved: bool,
150 edge_ticks: u32,
153}
154
155const EDGE_PAGE_EVERY: u32 = 5;
157
158pub struct DatePicker {
161 viewed_month: SimpleDate,
163 selected: Option<SimpleDate>,
164 today: Option<SimpleDate>,
165 min: Option<SimpleDate>,
166 max: Option<SimpleDate>,
167 mode: SelectionMode,
168 range: Option<(SimpleDate, Option<SimpleDate>)>,
170 accent: Option<Color>,
171 range_color: Option<Color>,
172 axis: PageAxis,
173 on_select: Option<OnSelectFn>,
177 on_month_change: Option<Arc<dyn Fn(SimpleDate) + Send + Sync>>,
178}
179
180type 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 pub fn mode(mut self, m: SelectionMode) -> Self { self.mode = m; self }
215 pub fn range(mut self, start: SimpleDate, end: Option<SimpleDate>) -> Self {
217 self.mode = SelectionMode::Range; self.range = Some((start, end)); self
218 }
219 pub fn range_color(mut self, c: Color) -> Self { self.range_color = Some(c); self }
221 pub fn axis(mut self, a: PageAxis) -> Self { self.axis = a; self }
223
224 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 #[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 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
252struct 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 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 let rows = rows_in_month(month); let band_h = CELL_H; 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 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 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 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]]); }));
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 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 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 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 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 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 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 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 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 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 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 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 {
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 s.anchor = Some(date);
568 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 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 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] 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 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 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 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 let (_, _, above, _) = day_at(cw * 3.5, -5.0, body, view);
672 assert!(above);
673 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] 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 assert_eq!(rows_in_month(SimpleDate::new(2026, 7, 1)), 5);
702 assert_eq!(SimpleDate::day_of_week(2026, 2, 1), 0);
704 assert_eq!(rows_in_month(SimpleDate::new(2026, 2, 1)), 4);
705 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 assert_eq!(SimpleDate::day_of_week(2024, 1, 1), 1);
750 assert_eq!(SimpleDate::day_of_week(2000, 1, 1), 6);
752 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}