First commit. Version 0.1.0
This commit is contained in:
617
src/widget/date_picker.rs
Normal file
617
src/widget/date_picker.rs
Normal file
@@ -0,0 +1,617 @@
|
||||
// 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,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
// 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( theme::HEADER_FS + 16.0 );
|
||||
let header_inner: Element<Msg> = stack::<Msg>()
|
||||
.push_aligned( prev, HAlign::Start, VAlign::Center )
|
||||
.push_aligned(
|
||||
text( title ).size( theme::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( theme::DOW_FS )
|
||||
.color( theme::text_muted() )
|
||||
.align_center(),
|
||||
)
|
||||
.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( theme::DAY_FS )
|
||||
.color( label_color )
|
||||
.align_center(),
|
||||
)
|
||||
.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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user