use super::date_utils::{days_in_month, parse_iso_date, DAY_NAMES, MONTH_NAMES};
#[cfg(test)]
use crate::core::Point;
use crate::core::{Color, Font, HorizontalAlignment, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::expect_string;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
pub(crate) type DateRange = (Option<(i32, u32, u32)>, Option<(i32, u32, u32)>);
fn day_of_week(year: i32, month: u32, day: u32) -> u32 {
let (y, m) = if month < 3 { (year - 1, month + 12) } else { (year, month) };
let c = y / 100;
let y = y % 100;
let d = day as i32;
let w = (d + (13 * (m as i32 + 1)) / 5 + y + y / 4 + c / 4 - 2 * c) % 7;
((w + 7) % 7) as u32
}
pub struct DateRangePicker {
base: BaseWidget,
display_year: i32,
display_month: u32,
start_date: Option<(i32, u32, u32)>,
end_date: Option<(i32, u32, u32)>,
hover_date: Option<(i32, u32, u32)>,
pub range_changed: Signal1<DateRange>,
}
impl DateRangePicker {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::DateRangePicker, geometry, "DateRangePicker"),
display_year: 2025,
display_month: 1,
start_date: None,
end_date: None,
hover_date: None,
range_changed: Signal1::new(),
}
}
pub fn start_date(&self) -> Option<(i32, u32, u32)> {
self.start_date
}
pub fn set_start_date(&mut self, date: Option<(i32, u32, u32)>) {
if self.start_date != date {
self.start_date = date;
self.emit_range_changed();
self.base.request_redraw();
}
}
pub fn end_date(&self) -> Option<(i32, u32, u32)> {
self.end_date
}
pub fn set_end_date(&mut self, date: Option<(i32, u32, u32)>) {
if self.end_date != date {
self.end_date = date;
self.emit_range_changed();
self.base.request_redraw();
}
}
pub fn clear_selection(&mut self) {
if self.start_date.is_some() || self.end_date.is_some() {
self.start_date = None;
self.end_date = None;
self.emit_range_changed();
self.base.request_redraw();
}
}
pub fn previous_month(&mut self) {
if self.display_month == 1 {
self.display_year -= 1;
self.display_month = 12;
} else {
self.display_month -= 1;
}
self.base.request_redraw();
}
pub fn next_month(&mut self) {
if self.display_month == 12 {
self.display_year += 1;
self.display_month = 1;
} else {
self.display_month += 1;
}
self.base.request_redraw();
}
pub fn display_year(&self) -> i32 {
self.display_year
}
pub fn display_month(&self) -> u32 {
self.display_month
}
fn emit_range_changed(&self) {
self.range_changed.emit((self.start_date, self.end_date));
}
fn month_name(&self) -> &str {
let idx = (self.display_month as usize).saturating_sub(1);
MONTH_NAMES[idx.min(11)]
}
fn days_in_display_month(&self) -> u32 {
days_in_month(self.display_year, self.display_month)
}
fn first_day_of_week(&self) -> u32 {
day_of_week(self.display_year, self.display_month, 1)
}
fn date_at_cell(&self, cell_index: usize) -> Option<(i32, u32, u32)> {
let first_dow = self.first_day_of_week() as usize;
let total_days = self.days_in_display_month() as usize;
if cell_index < first_dow || cell_index >= first_dow + total_days {
return None;
}
let day = (cell_index - first_dow + 1) as u32;
Some((self.display_year, self.display_month, day))
}
fn is_in_range(&self, date: (i32, u32, u32)) -> bool {
let Some(start) = self.start_date else {
return false;
};
if date_to_ordinal(date) < date_to_ordinal(start) {
return false;
}
if let Some(end) = self.end_date {
date_to_ordinal(date) <= date_to_ordinal(end)
} else {
date == start
}
}
fn grid_layout(&self) -> GridLayout {
let rect = self.geometry();
let total_days = self.days_in_display_month() as usize;
let first_dow = self.first_day_of_week() as usize;
let rows = ((first_dow + total_days).div_ceil(7) as u32).max(1);
let grid_left = rect.x + GRID_LEFT_INSET;
let grid_top = rect.y + HEADER_HEIGHT as i32 + DAY_HEADER_HEIGHT as i32;
let available_height = (rect.y + rect.height as i32 - grid_top).max(1) as u32;
let gaps = CELL_SPACING * (rows - 1);
let cell_size = (available_height.saturating_sub(gaps) / rows).clamp(1, NOMINAL_CELL);
GridLayout {
grid_left,
grid_top,
cell_size,
total_cell: cell_size + CELL_SPACING,
total_cells: first_dow + total_days,
}
}
}
const HEADER_HEIGHT: u32 = 40;
const DAY_HEADER_HEIGHT: u32 = 20;
const GRID_LEFT_INSET: i32 = 4;
const CELL_SPACING: u32 = 2;
const NOMINAL_CELL: u32 = 28;
struct GridLayout {
grid_left: i32,
grid_top: i32,
cell_size: u32,
total_cell: u32,
total_cells: usize,
}
fn date_to_ordinal(date: (i32, u32, u32)) -> i64 {
date.0 as i64 * 10000 + date.1 as i64 * 100 + date.2 as i64
}
fn format_iso_date(date: (i32, u32, u32)) -> String {
super::date_utils::format_iso_date(date.0, date.1, date.2)
}
impl Widget for DateRangePicker {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(500, 300)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for DateRangePicker {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"start_date" => Ok(match self.start_date() {
Some(date) => CapabilityValue::String(format_iso_date(date)),
None => CapabilityValue::Null,
}),
"end_date" => Ok(match self.end_date() {
Some(date) => CapabilityValue::String(format_iso_date(date)),
None => CapabilityValue::Null,
}),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"start_date" => {
self.set_start_date(expect_optional_iso_date(value)?);
Ok(())
}
"end_date" => {
self.set_end_date(expect_optional_iso_date(value)?);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["start_date", "end_date", BASE_PROPERTY_NAMES]
}
}
fn expect_optional_iso_date(
value: CapabilityValue,
) -> Result<Option<(i32, u32, u32)>, CapabilityAccessError> {
match value {
CapabilityValue::Null => Ok(None),
other => {
let text = expect_string(other)?;
parse_iso_date(&text).map(Some).ok_or(CapabilityAccessError::TypeMismatch)
}
}
}
impl Draw for DateRangePicker {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let is_enabled = self.base.is_enabled();
let style = self.base.style().clone();
let theme = crate::style::resolved_theme_style("date_range_picker");
let surface = style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or(Color::WHITE);
let text_color = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::BLACK);
let bg_color =
if is_enabled { surface.with_alpha(255) } else { surface.blend(&text_color, 0.1) };
let accent = theme
.as_ref()
.and_then(|_| crate::style::semantic_color(crate::style::SemanticColor::Info))
.unwrap_or(Color::BLUE);
context.fill_rect(rect, bg_color);
let layout = self.grid_layout();
let grid_left = layout.grid_left;
let grid_top = layout.grid_top;
let cell_size = layout.cell_size;
let total_cell = layout.total_cell;
let header_band = Rect::new(rect.x + 20, rect.y + 6, rect.width.saturating_sub(40), 18);
let header_font = Font::new("sans-serif", 14.0, true, false);
let header_text = format!("{} {}", self.month_name(), self.display_year);
context.draw_text_fitted(
header_band,
&header_text,
&header_font,
text_color,
HorizontalAlignment::Center,
);
let nav_font = Font::new("sans-serif", 12.0, true, false);
let nav_height = context.measure_text("◀", &nav_font).height;
context.draw_text_fitted(
Rect::new(rect.x + 6, rect.y + 8, 14, nav_height),
"◀",
&nav_font,
text_color,
HorizontalAlignment::Center,
);
context.draw_text_fitted(
Rect::new(rect.x + rect.width as i32 - 20, rect.y + 8, 14, nav_height),
"▶",
&nav_font,
text_color,
HorizontalAlignment::Center,
);
let dow_font = Font::new("sans-serif", 9.0, false, false);
let dow_color = surface.blend(&text_color, 0.55);
let dow_height = context.measure_text("M", &dow_font).height;
for (i, day_name) in DAY_NAMES.iter().enumerate() {
let cell_x = grid_left + (i as u32 * total_cell) as i32;
let cell_y = grid_top - DAY_HEADER_HEIGHT as i32;
context.draw_text_fitted(
Rect::new(cell_x, cell_y + 4, cell_size, dow_height),
day_name,
&dow_font,
dow_color,
HorizontalAlignment::Center,
);
}
let day_font = Font::new("sans-serif", 10.0, false, false);
let total_cells = layout.total_cells;
for cell_idx in 0..total_cells {
let Some(date) = self.date_at_cell(cell_idx) else {
continue;
};
let day = date.2;
let row = cell_idx / 7;
let col = cell_idx % 7;
let cell_x = grid_left + (col as u32 * total_cell) as i32;
let cell_y = grid_top + (row as u32 * total_cell) as i32;
let cell_rect = Rect::new(cell_x, cell_y, cell_size, cell_size);
let is_start = self.start_date == Some(date);
let is_end = self.end_date == Some(date);
let is_today = is_start || is_end;
let in_range = self.is_in_range(date);
let is_hover = self.hover_date == Some(date);
if in_range && is_enabled {
let range_color =
if is_start || is_end { accent.with_alpha(200) } else { accent.with_alpha(60) };
context.fill_rounded_rect(cell_rect, 4, range_color);
} else if is_hover && is_enabled {
context.fill_rounded_rect(cell_rect, 4, surface.blend(&text_color, 0.16));
} else if is_today && is_enabled {
let today_color = if is_start || is_end {
accent.with_alpha(200)
} else {
surface.blend(&text_color, 0.14)
};
context.fill_rounded_rect(cell_rect, 4, today_color);
}
let day_text = day.to_string();
let day_metrics = context.measure_text(&day_text, &day_font);
let day_y = cell_y + (cell_size as i32 - day_metrics.height as i32) / 2;
let day_color = if !is_enabled {
surface.blend(&text_color, 0.35)
} else if is_start || is_end {
surface
} else if in_range {
accent
} else {
text_color
};
context.draw_text_fitted(
Rect::new(cell_x, day_y, cell_size, day_metrics.height),
&day_text,
&day_font,
day_color,
HorizontalAlignment::Center,
);
}
}
}
impl EventHandler for DateRangePicker {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
if let Event::MousePress { pos, button: 1 } = event {
let rect = self.geometry();
if !rect.contains_point(*pos) {
return;
}
let layout = self.grid_layout();
let (grid_left, grid_top) = (layout.grid_left, layout.grid_top);
let total_cell = layout.total_cell;
let header_rect_left = Rect::new(rect.x + 4, rect.y + 4, 20, HEADER_HEIGHT);
let header_rect_right =
Rect::new(rect.x + rect.width as i32 - 24, rect.y + 4, 20, HEADER_HEIGHT);
if header_rect_left.contains_point(*pos) {
self.previous_month();
return;
}
if header_rect_right.contains_point(*pos) {
self.next_month();
return;
}
let rel_x = pos.x - grid_left;
let rel_y = pos.y - grid_top;
if rel_x < 0 || rel_y < 0 {
return;
}
let col = (rel_x as u32) / total_cell;
let row = (rel_y as u32) / total_cell;
if col >= 7 || row >= 6 {
if self.start_date.is_some() && self.end_date.is_none() {
self.clear_selection();
}
return;
}
let cell_idx = (row as usize) * 7 + (col as usize);
if cell_idx >= layout.total_cells {
return;
}
let Some(clicked_date) = self.date_at_cell(cell_idx) else {
return;
};
match (self.start_date, self.end_date) {
(None, _) => {
self.start_date = Some(clicked_date);
self.emit_range_changed();
self.base.request_redraw();
}
(Some(start), None) => {
let start_ord = date_to_ordinal(start);
let click_ord = date_to_ordinal(clicked_date);
if click_ord > start_ord {
self.end_date = Some(clicked_date);
} else if click_ord < start_ord {
self.start_date = Some(clicked_date);
self.end_date = Some(start);
} else {
self.clear_selection();
return;
}
self.emit_range_changed();
self.base.request_redraw();
}
(Some(_), Some(_)) => {
self.start_date = Some(clicked_date);
self.end_date = None;
self.emit_range_changed();
self.base.request_redraw();
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::svg::render_to_svg;
fn make_picker() -> DateRangePicker {
DateRangePicker::new(Rect::new(0, 0, 240, 260))
}
#[test]
fn date_range_picker_default_creation() {
let picker = make_picker();
assert_eq!(picker.kind(), WidgetKind::DateRangePicker);
assert!(picker.start_date().is_none());
assert!(picker.end_date().is_none());
assert_eq!(picker.display_year(), 2025);
assert_eq!(picker.display_month(), 1);
}
#[test]
fn date_range_picker_set_start_end() {
let mut picker = make_picker();
picker.set_start_date(Some((2025, 1, 10)));
assert_eq!(picker.start_date(), Some((2025, 1, 10)));
picker.set_end_date(Some((2025, 1, 20)));
assert_eq!(picker.end_date(), Some((2025, 1, 20)));
}
#[test]
fn date_range_picker_clear_selection() {
let mut picker = make_picker();
picker.set_start_date(Some((2025, 1, 10)));
picker.set_end_date(Some((2025, 1, 20)));
assert!(picker.start_date().is_some());
assert!(picker.end_date().is_some());
picker.clear_selection();
assert!(picker.start_date().is_none());
assert!(picker.end_date().is_none());
}
#[test]
fn date_range_picker_navigation() {
let mut picker = make_picker();
assert_eq!(picker.display_month(), 1);
picker.previous_month();
assert_eq!(picker.display_month(), 12);
assert_eq!(picker.display_year(), 2024);
picker.next_month();
assert_eq!(picker.display_month(), 1);
assert_eq!(picker.display_year(), 2025);
}
#[test]
fn date_range_picker_svg_output() {
let mut picker = make_picker();
picker.set_start_date(Some((2025, 1, 5)));
picker.set_end_date(Some((2025, 1, 15)));
let svg = render_to_svg(&mut picker);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn date_range_picker_mouse_press_sets_start() {
let mut picker = make_picker();
picker.handle_event(&Event::MousePress { pos: Point::new(124, 120), button: 1 });
assert_eq!(picker.start_date(), Some((2025, 1, 15)));
assert!(picker.end_date().is_none());
}
#[test]
fn date_range_picker_mouse_press_clears_existing() {
let mut picker = make_picker();
picker.set_start_date(Some((2025, 1, 10)));
picker.handle_event(&Event::MousePress {
pos: Point::new(4, 250), button: 1,
});
assert!(picker.start_date().is_none());
}
}