First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

617
src/widget/date_picker.rs Normal file
View 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 112, `day` is 131 (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();
}
}