`Container::max_width(px)` mirrors the same flag on `Column` / `Row` — the container reports `min( offered, px )` upwards and the draw pass caps `rect.width` to `px`, so a decorated child wrapped in `container().max_width(260)` no longer needs a `column()`-of-one shell just to access the cap. Propagated through `map_msg`, covered by a test, and documented under the `container` section of `docs/widgets.md`.
`Text::no_truncate()` opts out of the default ellipsis behaviour: when `truncate = false` the draw pass paints the full string even if `measure(text) > rect.width`. Useful for very short labels (calendar days "1"–"31", day-of-week stubs "Lu" / "Mi" / "Sá") inside grid slots whose width is dictated by the parent — a couple of pixels of overflow centred in the rect is invisible, while "..." in place of a single-digit number is loud.
`DatePicker::width(px)` is a layout hint: when set, `build()` derives `header_fs`, `dow_fs` and `day_fs` from the slot width so the worst-case label in each row ("September 2026" in the header, "Mié" / "Sáb" in the DOW row, "30" in the day cell) fits at a sensible size; the design defaults stay as upper bounds. Day and DOW cells also flip on `Text::no_truncate()` as a safety net — heuristic font sizing can't perfectly predict glyph widths across families, and overflowing one pixel beats truncating to "...".
645 lines
21 KiB
Rust
645 lines
21 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||
|
||
//! DatePicker — month-grid calendar widget.
|
||
//!
|
||
//! Stateless: the application owns the selected date and the
|
||
//! currently-visible month. Two callbacks bridge widget → app:
|
||
//!
|
||
//! - [`DatePicker::on_change`] fires when the user taps a day cell;
|
||
//! the message carries the picked [`Date`].
|
||
//! - [`DatePicker::on_navigate`] fires when the user taps the
|
||
//! previous / next-month arrows; the message carries the new
|
||
//! `(year, month)` that the calendar should display.
|
||
//!
|
||
//! `on_navigate` is optional — when not wired, the navigation arrows
|
||
//! render disabled. The application typically stores both `date:
|
||
//! Date` and a `view: Date` (or `(view_year, view_month)`) in its
|
||
//! state and updates `view` from `on_navigate` to let the user scroll
|
||
//! through months without changing the selection.
|
||
//!
|
||
//! Date arithmetic (leap years, day-of-week, month wraparound) is
|
||
//! built in — no `chrono` / `time` dependency. Limited to the
|
||
//! Gregorian calendar from year 1 onwards (Zeller's congruence).
|
||
//!
|
||
//! ```rust,no_run
|
||
//! # use ltk::{ date_picker, Date, DatePicker };
|
||
//! # #[ derive( Clone ) ] enum Msg { DateChanged( Date ), DateView( i32, u8 ) }
|
||
//! # struct App { date: Date, view_year: i32, view_month: u8, today: Date }
|
||
//! # impl App { fn _ex( &self ) -> DatePicker<Msg> {
|
||
//! date_picker( self.date )
|
||
//! .view( self.view_year, self.view_month )
|
||
//! .today( self.today )
|
||
//! .on_change( Msg::DateChanged )
|
||
//! .on_navigate( |y, m| Msg::DateView( y, m ) )
|
||
//! # }}
|
||
//! ```
|
||
|
||
use std::sync::Arc;
|
||
|
||
use crate::layout::column::column;
|
||
use crate::layout::spacer::spacer;
|
||
use crate::layout::wrap_grid::grid;
|
||
|
||
use super::Element;
|
||
|
||
mod theme
|
||
{
|
||
use crate::types::Color;
|
||
pub fn surface() -> Color { crate::theme::palette().surface }
|
||
pub fn surface_alt() -> Color { crate::theme::palette().surface_alt }
|
||
pub fn text() -> Color { crate::theme::palette().text_primary }
|
||
pub fn text_muted() -> Color { crate::theme::palette().text_secondary }
|
||
pub fn accent() -> Color { crate::theme::palette().accent }
|
||
pub const PADDING: f32 = 16.0;
|
||
pub const RADIUS: f32 = 16.0;
|
||
pub const HEADER_FS: f32 = 16.0;
|
||
pub const DOW_FS: f32 = 12.0;
|
||
pub const DAY_FS: f32 = 14.0;
|
||
pub const CELL_SIZE: f32 = 36.0;
|
||
pub const SPACING: f32 = 4.0;
|
||
}
|
||
|
||
/// A calendar date in the proleptic Gregorian calendar. No time
|
||
/// component, no timezone. `month` is 1–12, `day` is 1–31 (further
|
||
/// constrained by [`days_in_month`]).
|
||
#[ derive( Clone, Copy, Debug, PartialEq, Eq, Hash ) ]
|
||
pub struct Date
|
||
{
|
||
pub year: i32,
|
||
pub month: u8,
|
||
pub day: u8,
|
||
}
|
||
|
||
impl Date
|
||
{
|
||
/// Construct a [`Date`] without validating bounds. Callers that
|
||
/// might pass user-controlled data should run [`Self::is_valid`]
|
||
/// first.
|
||
pub const fn new( year: i32, month: u8, day: u8 ) -> Self
|
||
{
|
||
Self { year, month, day }
|
||
}
|
||
|
||
/// `true` when `month` is in `1..=12` and `day` is a real day of
|
||
/// that month / year (29-Feb in leap years, etc.).
|
||
pub fn is_valid( self ) -> bool
|
||
{
|
||
( 1..=12 ).contains( &self.month )
|
||
&& self.day >= 1
|
||
&& self.day <= days_in_month( self.year, self.month )
|
||
}
|
||
}
|
||
|
||
/// `true` when `year` is a leap year in the proleptic Gregorian
|
||
/// calendar.
|
||
pub fn is_leap_year( year: i32 ) -> bool
|
||
{
|
||
( year % 4 == 0 && year % 100 != 0 ) || year % 400 == 0
|
||
}
|
||
|
||
/// Number of days in `month` of `year` (1-indexed month). Returns 0
|
||
/// for invalid month numbers.
|
||
pub fn days_in_month( year: i32, month: u8 ) -> u8
|
||
{
|
||
match month
|
||
{
|
||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||
4 | 6 | 9 | 11 => 30,
|
||
2 => if is_leap_year( year ) { 29 } else { 28 },
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
/// Day of the week for `(year, month, day)`. `0 = Sunday`,
|
||
/// `1 = Monday`, …, `6 = Saturday`. Uses Zeller's congruence;
|
||
/// undefined for years before AD 1.
|
||
pub fn day_of_week( year: i32, month: u8, day: u8 ) -> u8
|
||
{
|
||
let ( m, y ) = if month < 3 { ( month as i32 + 12, year - 1 ) } else { ( month as i32, year ) };
|
||
let k = y.rem_euclid( 100 );
|
||
let j = y.div_euclid( 100 );
|
||
let h = ( day as i32 + ( 13 * ( m + 1 ) ) / 5 + k + k / 4 + j / 4 + 5 * j ).rem_euclid( 7 );
|
||
// h: 0=Saturday, 1=Sunday, 2=Monday, ..., 6=Friday → re-map to 0=Sun..6=Sat
|
||
( ( h + 6 ).rem_euclid( 7 ) ) as u8
|
||
}
|
||
|
||
/// Add `delta` months to `(year, month)` with wraparound. Negative
|
||
/// deltas walk backwards; year crosses are handled.
|
||
pub fn add_months( year: i32, month: u8, delta: i32 ) -> ( i32, u8 )
|
||
{
|
||
let total = year * 12 + ( month as i32 ) - 1 + delta;
|
||
let new_year = total.div_euclid( 12 );
|
||
let new_month = ( total.rem_euclid( 12 ) + 1 ) as u8;
|
||
( new_year, new_month )
|
||
}
|
||
|
||
/// Locale settings for the date picker. Month names and day-of-week
|
||
/// labels are pulled from the i18n registry via
|
||
/// [`rust_i18n::t!`] so changing the active locale (e.g. `set_locale("es")`)
|
||
/// flips the calendar without recreating the widget. The remaining piece is
|
||
/// the **first day of the week**, which varies independently of language
|
||
/// (US starts on Sunday, ES / FR / DE on Monday) — that one stays as a
|
||
/// builder field.
|
||
#[ derive( Clone, Copy, Debug ) ]
|
||
pub struct Locale
|
||
{
|
||
/// First day of the week (0 = Sunday, 1 = Monday). Default 1
|
||
/// (Monday) — the convention used across most of Europe.
|
||
pub first_dow: u8,
|
||
}
|
||
|
||
impl Locale
|
||
{
|
||
/// Locale starting the week on Monday. The default.
|
||
pub const MONDAY_FIRST: Self = Self { first_dow: 1 };
|
||
/// Locale starting the week on Sunday — common in US English.
|
||
pub const SUNDAY_FIRST: Self = Self { first_dow: 0 };
|
||
}
|
||
|
||
impl Default for Locale
|
||
{
|
||
fn default() -> Self { Self::MONDAY_FIRST }
|
||
}
|
||
|
||
/// Translated month name (1-indexed: `month = 1` → January, …,
|
||
/// `month = 12` → December). Resolves through the i18n registry, so
|
||
/// `set_locale("es")` returns `"Enero"`, etc.
|
||
fn month_name( month: u8 ) -> String
|
||
{
|
||
match month
|
||
{
|
||
1 => rust_i18n::t!( "date_picker.month_1" ).to_string(),
|
||
2 => rust_i18n::t!( "date_picker.month_2" ).to_string(),
|
||
3 => rust_i18n::t!( "date_picker.month_3" ).to_string(),
|
||
4 => rust_i18n::t!( "date_picker.month_4" ).to_string(),
|
||
5 => rust_i18n::t!( "date_picker.month_5" ).to_string(),
|
||
6 => rust_i18n::t!( "date_picker.month_6" ).to_string(),
|
||
7 => rust_i18n::t!( "date_picker.month_7" ).to_string(),
|
||
8 => rust_i18n::t!( "date_picker.month_8" ).to_string(),
|
||
9 => rust_i18n::t!( "date_picker.month_9" ).to_string(),
|
||
10 => rust_i18n::t!( "date_picker.month_10" ).to_string(),
|
||
11 => rust_i18n::t!( "date_picker.month_11" ).to_string(),
|
||
12 => rust_i18n::t!( "date_picker.month_12" ).to_string(),
|
||
_ => "?".to_string(),
|
||
}
|
||
}
|
||
|
||
/// Translated single-letter day-of-week label keyed by `dow` where
|
||
/// `0 = Sunday`, …, `6 = Saturday`. The display order in the calendar
|
||
/// header is rotated by [`Locale::first_dow`].
|
||
fn dow_short( dow: u8 ) -> String
|
||
{
|
||
match dow
|
||
{
|
||
0 => rust_i18n::t!( "date_picker.dow_short_0" ).to_string(),
|
||
1 => rust_i18n::t!( "date_picker.dow_short_1" ).to_string(),
|
||
2 => rust_i18n::t!( "date_picker.dow_short_2" ).to_string(),
|
||
3 => rust_i18n::t!( "date_picker.dow_short_3" ).to_string(),
|
||
4 => rust_i18n::t!( "date_picker.dow_short_4" ).to_string(),
|
||
5 => rust_i18n::t!( "date_picker.dow_short_5" ).to_string(),
|
||
6 => rust_i18n::t!( "date_picker.dow_short_6" ).to_string(),
|
||
_ => "?".to_string(),
|
||
}
|
||
}
|
||
|
||
/// Calendar date selector.
|
||
pub struct DatePicker<Msg: Clone>
|
||
{
|
||
pub value: Date,
|
||
pub view_year: i32,
|
||
pub view_month: u8,
|
||
pub today: Option<Date>,
|
||
pub on_change: Option<Arc<dyn Fn( Date ) -> Msg>>,
|
||
pub on_navigate: Option<Arc<dyn Fn( i32, u8 ) -> Msg>>,
|
||
pub locale: Locale,
|
||
pub width: Option<f32>,
|
||
}
|
||
|
||
impl<Msg: Clone + 'static> DatePicker<Msg>
|
||
{
|
||
/// Create a date picker with the given selected date. The view
|
||
/// month defaults to the same month as `value`; override with
|
||
/// [`Self::view`] when the user is browsing without selecting.
|
||
pub fn new( value: Date ) -> Self
|
||
{
|
||
Self
|
||
{
|
||
value,
|
||
view_year: value.year,
|
||
view_month: value.month,
|
||
today: None,
|
||
on_change: None,
|
||
on_navigate: None,
|
||
locale: Locale::default(),
|
||
width: None,
|
||
}
|
||
}
|
||
|
||
/// Override the visible month. Call this from your view function
|
||
/// with whatever `(year, month)` your application state stores
|
||
/// for "the calendar's current page".
|
||
pub fn view( mut self, year: i32, month: u8 ) -> Self
|
||
{
|
||
self.view_year = year;
|
||
self.view_month = month;
|
||
self
|
||
}
|
||
|
||
/// Mark a specific date as "today" — drawn with a subtle accent
|
||
/// ring even if it is not selected.
|
||
pub fn today( mut self, today: Date ) -> Self
|
||
{
|
||
self.today = Some( today );
|
||
self
|
||
}
|
||
|
||
/// Day-tap callback. Required for the picker to be interactive.
|
||
pub fn on_change( mut self, f: impl Fn( Date ) -> Msg + 'static ) -> Self
|
||
{
|
||
self.on_change = Some( Arc::new( f ) );
|
||
self
|
||
}
|
||
|
||
/// Arrow-tap callback. The runtime calls `f(new_year, new_month)`
|
||
/// when the user taps prev / next. Wire to your view-month state.
|
||
pub fn on_navigate( mut self, f: impl Fn( i32, u8 ) -> Msg + 'static ) -> Self
|
||
{
|
||
self.on_navigate = Some( Arc::new( f ) );
|
||
self
|
||
}
|
||
|
||
/// Override the locale (month / day-of-week names + week start).
|
||
pub fn locale( mut self, l: Locale ) -> Self
|
||
{
|
||
self.locale = l;
|
||
self
|
||
}
|
||
|
||
/// Outer width the picker will be laid out at. Used to shrink the
|
||
/// header / day-of-week / day-cell font sizes so the widest labels
|
||
/// ("30", "September 2026", "Mié") still fit without ellipsis. When
|
||
/// unset, the picker uses the design defaults and may truncate
|
||
/// inside very narrow rects.
|
||
pub fn width( mut self, w: f32 ) -> Self
|
||
{
|
||
self.width = Some( w );
|
||
self
|
||
}
|
||
|
||
/// Build the `Element` tree representing this date picker.
|
||
pub fn build( self ) -> Element<Msg>
|
||
{
|
||
use super::{ button, container, icon_button, text };
|
||
use super::pressable::pressable;
|
||
use super::button::ButtonVariant;
|
||
use crate::layout::stack::{ stack, HAlign, VAlign };
|
||
|
||
let view_y = self.view_year;
|
||
let view_m = self.view_month.clamp( 1, 12 );
|
||
let today = self.today;
|
||
let value = self.value;
|
||
let on_chg = self.on_change.clone();
|
||
let on_nav = self.on_navigate.clone();
|
||
let first = self.locale.first_dow;
|
||
|
||
let ( header_fs, dow_fs, day_fs ) = if let Some( w ) = self.width
|
||
{
|
||
let inner_w = ( w - 2.0 * theme::PADDING ).max( 8.0 );
|
||
let slot_w = ( ( inner_w - 6.0 * theme::SPACING ) / 7.0 ).max( 4.0 );
|
||
let day = ( ( slot_w - 8.0 ) / 1.6 ).clamp( 8.0, theme::DAY_FS );
|
||
let dow = ( slot_w / 2.0 ).clamp( 8.0, theme::DOW_FS );
|
||
let head = ( ( inner_w - 52.0 ) / ( 14.0 * 0.7 ) ).clamp( 10.0, theme::HEADER_FS );
|
||
( head, dow, day )
|
||
} else {
|
||
( theme::HEADER_FS, theme::DOW_FS, theme::DAY_FS )
|
||
};
|
||
|
||
// Header chevrons load from the active theme as SVG icons
|
||
// (`icons/catalogue/filled/multimedia/{previous,next}.svg`),
|
||
// tinted to the primary text colour so they read against
|
||
// either light or dark surfaces. Sized small so the buttons
|
||
// sit visually inside the grid's first / last column.
|
||
// Falls back to the matching Unicode glyph if the icon is
|
||
// missing from the theme.
|
||
const CHEVRON_PX: u32 = 18;
|
||
let nav_button = | name: &str, fallback: &str | -> super::button::Button<Msg>
|
||
{
|
||
match crate::theme::icon_rgba( name, CHEVRON_PX )
|
||
{
|
||
Some( ( rgba, w, h ) ) =>
|
||
{
|
||
let tinted = std::sync::Arc::new(
|
||
crate::theme::tint_symbolic( &rgba, theme::text() ),
|
||
);
|
||
icon_button::<Msg>( tinted, w, h ).icon_size( CHEVRON_PX as f32 )
|
||
}
|
||
None => button::<Msg>( fallback ).variant( ButtonVariant::Tertiary ),
|
||
}
|
||
};
|
||
|
||
let title = format!( "{} {}", month_name( view_m ), view_y );
|
||
let mut prev = nav_button( "multimedia/previous", "‹" );
|
||
let mut next = nav_button( "multimedia/next", "›" );
|
||
if let Some( ref nav ) = on_nav
|
||
{
|
||
let ( py, pm ) = add_months( view_y, view_m, -1 );
|
||
let ( ny, nm ) = add_months( view_y, view_m, 1 );
|
||
let nav_p = nav.clone();
|
||
let nav_n = nav.clone();
|
||
prev = prev.on_press( nav_p( py, pm ) );
|
||
next = next.on_press( nav_n( ny, nm ) );
|
||
}
|
||
|
||
// Header: title centred horizontally; prev pinned to the left
|
||
// edge, next pinned to the right edge — both at the same
|
||
// padding offset as the calendar grid below, so they align
|
||
// vertically with the first and last day columns. Built as a
|
||
// `Stack` (the only layout that lets independent children
|
||
// sit at left / centre / right of the same rect) wrapped in a
|
||
// container that fixes the row height.
|
||
let header_height = ( CHEVRON_PX as f32 + 16.0 ).max( header_fs + 16.0 );
|
||
let header_inner: Element<Msg> = stack::<Msg>()
|
||
.push_aligned( prev, HAlign::Start, VAlign::Center )
|
||
.push_aligned(
|
||
text( title ).size( header_fs ).color( theme::text() ),
|
||
HAlign::Center, VAlign::Center,
|
||
)
|
||
.push_aligned( next, HAlign::End, VAlign::Center )
|
||
.into();
|
||
let header: Element<Msg> = container::<Msg>( header_inner )
|
||
.padding_v( ( header_height - CHEVRON_PX as f32 ) * 0.5 )
|
||
.padding_h( 0.0 )
|
||
.into();
|
||
|
||
// Day-of-week row in the locale's display order. Built as a
|
||
// 7-column `wrap_grid` so the columns share the same
|
||
// equal-width slots that the day-cell grid below will use,
|
||
// guaranteeing letter-and-number alignment frame to frame
|
||
// regardless of glyph width (`W` is wider than `M`, etc.).
|
||
let mut dow_row = grid::<Msg>( 7 ).spacing( theme::SPACING );
|
||
for slot in 0..7u8
|
||
{
|
||
// Convert the column index (display order) back to a
|
||
// weekday number (0 = Sunday) honouring `first_dow`.
|
||
let dow = ( ( slot + first ) % 7 ) as u8;
|
||
let cell: Element<Msg> = container::<Msg>(
|
||
text( dow_short( dow ) )
|
||
.size( dow_fs )
|
||
.color( theme::text_muted() )
|
||
.align_center()
|
||
.no_truncate(),
|
||
)
|
||
.padding_h( 0.0 )
|
||
.padding_v( 4.0 )
|
||
.into();
|
||
dow_row = dow_row.push( cell );
|
||
}
|
||
|
||
// Day grid: 7 columns × 6 rows of equal-width cells. Off-month
|
||
// slots stay empty so the calendar always reserves the same
|
||
// height; the `wrap_grid` lays out each cell in its own slot
|
||
// so vertical alignment with the DOW row above is automatic.
|
||
let dim = days_in_month( view_y, view_m );
|
||
let first_dow = day_of_week( view_y, view_m, 1 );
|
||
let leading_blanks = ( ( first_dow as i32 - first as i32 ).rem_euclid( 7 ) ) as u8;
|
||
let total_cells = 6 * 7;
|
||
|
||
let mut day_grid = grid::<Msg>( 7 ).spacing( theme::SPACING );
|
||
for slot in 0..total_cells
|
||
{
|
||
let day_num = slot as i32 - leading_blanks as i32 + 1;
|
||
let cell: Element<Msg> = if day_num < 1 || day_num > dim as i32
|
||
{
|
||
container::<Msg>( spacer() ).padding( 0.0 ).into()
|
||
} else {
|
||
let day = day_num as u8;
|
||
let date = Date::new( view_y, view_m, day );
|
||
let is_selected = date == value;
|
||
let is_today = today == Some( date );
|
||
|
||
let label_color = if is_selected { theme::surface() } else { theme::text() };
|
||
let bg = if is_selected
|
||
{
|
||
Some( theme::accent() )
|
||
} else if is_today {
|
||
Some( theme::surface_alt() )
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let mut card = container::<Msg>(
|
||
text( format!( "{}", day ) )
|
||
.size( day_fs )
|
||
.color( label_color )
|
||
.align_center()
|
||
.no_truncate(),
|
||
)
|
||
.padding_h( 4.0 )
|
||
.padding_v( 8.0 )
|
||
.radius( theme::CELL_SIZE * 0.5 );
|
||
if let Some( c ) = bg { card = card.background( c ); }
|
||
|
||
if let Some( ref cb ) = on_chg
|
||
{
|
||
let m = cb( date );
|
||
pressable( card ).on_press( m ).into()
|
||
} else {
|
||
card.into()
|
||
}
|
||
};
|
||
day_grid = day_grid.push( cell );
|
||
}
|
||
|
||
container::<Msg>(
|
||
column::<Msg>()
|
||
.spacing( 0.0 )
|
||
.push( header )
|
||
// Extra breathing space between the month title and
|
||
// the day-of-week header so the row separation reads
|
||
// cleanly. Tuned by feedback — the previous shared
|
||
// `SPACING * 2.0` was too tight.
|
||
.push( spacer().height( theme::SPACING * 4.0 ) )
|
||
.push( dow_row )
|
||
.push( spacer().height( theme::SPACING * 1.5 ) )
|
||
.push( day_grid ),
|
||
)
|
||
.background( theme::surface_alt() )
|
||
.padding( theme::PADDING )
|
||
.radius( theme::RADIUS )
|
||
.into()
|
||
}
|
||
}
|
||
|
||
impl<Msg: Clone + 'static> From<DatePicker<Msg>> for Element<Msg>
|
||
{
|
||
fn from( d: DatePicker<Msg> ) -> Self { d.build() }
|
||
}
|
||
|
||
/// Create a [`DatePicker`] with the given selected date.
|
||
///
|
||
/// ```rust,no_run
|
||
/// # use ltk::{ date_picker, Date, DatePicker };
|
||
/// # #[ derive( Clone ) ] enum Msg { DateChanged( Date ), DateView( i32, u8 ) }
|
||
/// # struct App { date: Date, today: Date }
|
||
/// # impl App { fn _ex( &self ) -> DatePicker<Msg> {
|
||
/// date_picker( self.date )
|
||
/// .today( self.today )
|
||
/// .on_change( Msg::DateChanged )
|
||
/// .on_navigate( Msg::DateView )
|
||
/// # }}
|
||
/// ```
|
||
pub fn date_picker<Msg: Clone + 'static>( value: Date ) -> DatePicker<Msg>
|
||
{
|
||
DatePicker::new( value )
|
||
}
|
||
|
||
#[ cfg( test ) ]
|
||
mod tests
|
||
{
|
||
use super::*;
|
||
|
||
// ── leap years ────────────────────────────────────────────────────────────
|
||
|
||
#[ test ]
|
||
fn leap_year_rules()
|
||
{
|
||
assert!( is_leap_year( 2024 ) );
|
||
assert!( is_leap_year( 2000 ) ); // div by 400
|
||
assert!( !is_leap_year( 1900 ) ); // div by 100 but not 400
|
||
assert!( !is_leap_year( 2023 ) );
|
||
assert!( is_leap_year( -4 ) ); // proleptic
|
||
}
|
||
|
||
#[ test ]
|
||
fn days_in_month_handles_leap_february()
|
||
{
|
||
assert_eq!( days_in_month( 2024, 2 ), 29 );
|
||
assert_eq!( days_in_month( 2023, 2 ), 28 );
|
||
assert_eq!( days_in_month( 2024, 4 ), 30 );
|
||
assert_eq!( days_in_month( 2024, 7 ), 31 );
|
||
}
|
||
|
||
#[ test ]
|
||
fn days_in_month_zero_for_invalid_month()
|
||
{
|
||
assert_eq!( days_in_month( 2024, 0 ), 0 );
|
||
assert_eq!( days_in_month( 2024, 13 ), 0 );
|
||
}
|
||
|
||
// ── day of week ───────────────────────────────────────────────────────────
|
||
|
||
#[ test ]
|
||
fn day_of_week_known_dates()
|
||
{
|
||
// 1970-01-01 was a Thursday → 4.
|
||
assert_eq!( day_of_week( 1970, 1, 1 ), 4 );
|
||
// 2000-01-01 was a Saturday → 6.
|
||
assert_eq!( day_of_week( 2000, 1, 1 ), 6 );
|
||
// 2024-02-29 was a Thursday → 4 (leap day).
|
||
assert_eq!( day_of_week( 2024, 2, 29 ), 4 );
|
||
// 2026-05-02 (today, per user clock) was a Saturday → 6.
|
||
assert_eq!( day_of_week( 2026, 5, 2 ), 6 );
|
||
}
|
||
|
||
// ── month arithmetic ──────────────────────────────────────────────────────
|
||
|
||
#[ test ]
|
||
fn add_months_within_year()
|
||
{
|
||
assert_eq!( add_months( 2024, 3, 1 ), ( 2024, 4 ) );
|
||
assert_eq!( add_months( 2024, 3, -1 ), ( 2024, 2 ) );
|
||
}
|
||
|
||
#[ test ]
|
||
fn add_months_wraps_into_next_year()
|
||
{
|
||
assert_eq!( add_months( 2024, 12, 1 ), ( 2025, 1 ) );
|
||
}
|
||
|
||
#[ test ]
|
||
fn add_months_wraps_into_previous_year()
|
||
{
|
||
assert_eq!( add_months( 2024, 1, -1 ), ( 2023, 12 ) );
|
||
}
|
||
|
||
#[ test ]
|
||
fn add_months_handles_large_deltas()
|
||
{
|
||
assert_eq!( add_months( 2024, 1, 25 ), ( 2026, 2 ) );
|
||
assert_eq!( add_months( 2024, 1, -25 ), ( 2021, 12 ) );
|
||
}
|
||
|
||
// ── Date validity ─────────────────────────────────────────────────────────
|
||
|
||
#[ test ]
|
||
fn date_is_valid_on_realistic_inputs()
|
||
{
|
||
assert!( Date::new( 2024, 2, 29 ).is_valid() );
|
||
assert!( !Date::new( 2023, 2, 29 ).is_valid() ); // not leap
|
||
assert!( !Date::new( 2024, 4, 31 ).is_valid() ); // april has 30
|
||
assert!( !Date::new( 2024, 0, 1 ).is_valid() );
|
||
assert!( !Date::new( 2024, 13, 1 ).is_valid() );
|
||
assert!( !Date::new( 2024, 1, 0 ).is_valid() );
|
||
}
|
||
|
||
// ── builders ──────────────────────────────────────────────────────────────
|
||
|
||
#[ derive( Clone, Debug, PartialEq, Eq ) ]
|
||
enum Msg
|
||
{
|
||
Pick( Date ),
|
||
Nav( i32, u8 ),
|
||
}
|
||
|
||
#[ test ]
|
||
fn defaults_view_to_value_month()
|
||
{
|
||
let d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) );
|
||
assert_eq!( d.view_year, 2024 );
|
||
assert_eq!( d.view_month, 7 );
|
||
}
|
||
|
||
#[ test ]
|
||
fn view_builder_overrides()
|
||
{
|
||
let d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) ).view( 2025, 1 );
|
||
assert_eq!( d.view_year, 2025 );
|
||
assert_eq!( d.view_month, 1 );
|
||
}
|
||
|
||
#[ test ]
|
||
fn on_change_callback_invokes_with_date()
|
||
{
|
||
let d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) )
|
||
.on_change( Msg::Pick );
|
||
let cb = d.on_change.as_ref().expect( "set" );
|
||
assert_eq!( cb( Date::new( 2024, 7, 16 ) ), Msg::Pick( Date::new( 2024, 7, 16 ) ) );
|
||
}
|
||
|
||
#[ test ]
|
||
fn on_navigate_callback_invokes_with_year_month()
|
||
{
|
||
let d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) )
|
||
.on_navigate( Msg::Nav );
|
||
let cb = d.on_navigate.as_ref().expect( "set" );
|
||
assert_eq!( cb( 2025, 1 ), Msg::Nav( 2025, 1 ) );
|
||
}
|
||
|
||
#[ test ]
|
||
fn build_does_not_panic_on_minimal_config()
|
||
{
|
||
let _: Element<Msg> = date_picker::<Msg>( Date::new( 2024, 7, 15 ) ).build();
|
||
}
|
||
|
||
#[ test ]
|
||
fn build_does_not_panic_when_view_month_is_clamped()
|
||
{
|
||
// `view_month = 0` is invalid — build clamps to 1 instead of
|
||
// indexing month_names at -1.
|
||
let mut d: DatePicker<Msg> = date_picker( Date::new( 2024, 7, 15 ) );
|
||
d.view_month = 0;
|
||
let _: Element<Msg> = d.build();
|
||
}
|
||
}
|