// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! 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 { //! 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 { pub value: Date, pub view_year: i32, pub view_month: u8, pub today: Option, pub on_change: Option Msg>>, pub on_navigate: Option Msg>>, pub locale: Locale, pub width: Option, } impl DatePicker { /// 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 { 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 { 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::( tinted, w, h ).icon_size( CHEVRON_PX as f32 ) } None => button::( 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 = stack::() .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 = container::( 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::( 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 = container::( 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::( 7 ).spacing( theme::SPACING ); for slot in 0..total_cells { let day_num = slot as i32 - leading_blanks as i32 + 1; let cell: Element = if day_num < 1 || day_num > dim as i32 { container::( 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::( 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::( column::() .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 From> for Element { fn from( d: DatePicker ) -> 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 { /// date_picker( self.date ) /// .today( self.today ) /// .on_change( Msg::DateChanged ) /// .on_navigate( Msg::DateView ) /// # }} /// ``` pub fn date_picker( value: Date ) -> DatePicker { 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 = 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 = 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 = 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 = 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 = date_picker::( 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 = date_picker( Date::new( 2024, 7, 15 ) ); d.view_month = 0; let _: Element = d.build(); } }