use super::date_utils::{days_in_month, parse_iso_date, DAY_NAMES, MONTH_NAMES};
use crate::core::{Color, HorizontalAlignment, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::{RenderContext, TEXT_FIT_MARGIN};
use crate::signal::Signal1;
use crate::widget::capability::coercion::{expect_string, expect_string_list};
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::metrics::role_font;
use crate::widget::{BaseWidget, Draw, IconName, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use chrono::Datelike;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DatePreset {
pub label: String,
pub start: Option<(i32, u32, u32)>,
pub end: Option<(i32, u32, u32)>,
}
impl DatePreset {
pub fn new(label: impl Into<String>, start: (i32, u32, u32), end: (i32, u32, u32)) -> Self {
Self { label: label.into(), start: Some(start), end: Some(end) }
}
}
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 + 6).rem_euclid(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)>,
presets: Vec<DatePreset>,
double_month: bool,
min_nights: Option<u32>,
max_nights: Option<u32>,
pub range_changed: Signal1<DateRange>,
pub preset_selected: Signal1<usize>,
minimum_date: Option<(i32, u32, u32)>,
maximum_date: Option<(i32, u32, u32)>,
focus_date: Option<(i32, u32, u32)>,
digit_metrics: crate::compat::RefCell<DigitMetrics>,
}
type FontKey = (u32, u16, u64);
#[derive(Default)]
struct DigitMetrics {
day_key: Option<FontKey>,
day_height: u32,
header_key: Option<FontKey>,
header_height: u32,
}
fn font_key(font: &crate::core::Font) -> FontKey {
let mut family_hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in font.family().as_bytes() {
family_hash ^= u64::from(*byte);
family_hash = family_hash.wrapping_mul(0x0000_0100_0000_01b3);
}
(font.size().to_bits(), font.weight(), family_hash)
}
impl DateRangePicker {
pub fn new(geometry: Rect) -> Self {
let today = chrono::Local::now().date_naive();
Self {
base: BaseWidget::new(WidgetKind::DateRangePicker, geometry, "DateRangePicker"),
display_year: today.year(),
display_month: today.month(),
start_date: None,
end_date: None,
hover_date: None,
presets: Vec::new(),
double_month: false,
min_nights: None,
max_nights: None,
range_changed: Signal1::new(),
preset_selected: Signal1::new(),
minimum_date: None,
maximum_date: None,
focus_date: None,
digit_metrics: crate::compat::RefCell::new(DigitMetrics::default()),
}
}
pub fn set_current_month(&mut self) {
let today = chrono::Local::now().date_naive();
self.display_year = today.year();
self.display_month = today.month();
self.base.request_redraw();
}
pub fn set_display_page(&mut self, year: i32, month: u32) {
let month = month.clamp(1, 12);
if self.display_year == year && self.display_month == month {
return;
}
self.display_year = year;
self.display_month = month;
self.base.request_redraw();
}
pub fn go_to_today(&mut self) {
let today = chrono::Local::now().date_naive();
self.display_year = today.year();
self.display_month = today.month();
let date = (today.year(), today.month(), today.day());
self.start_date = Some(date);
self.end_date = Some(date);
self.emit_range_changed();
self.base.request_redraw();
}
pub fn presets(&self) -> &[DatePreset] {
&self.presets
}
pub fn set_presets(&mut self, presets: Vec<DatePreset>) {
if self.presets == presets {
return;
}
self.presets = presets;
self.base.request_redraw();
}
pub fn double_month(&self) -> bool {
self.double_month
}
pub fn set_double_month(&mut self, enabled: bool) {
if self.double_month == enabled {
return;
}
self.double_month = enabled;
self.base.request_redraw();
}
pub fn min_nights(&self) -> Option<u32> {
self.min_nights
}
pub fn set_min_nights(&mut self, nights: Option<u32>) {
let nights = nights.filter(|n| *n > 0);
if self.min_nights == nights {
return;
}
self.min_nights = nights;
self.base.request_redraw();
}
pub fn max_nights(&self) -> Option<u32> {
self.max_nights
}
pub fn set_max_nights(&mut self, nights: Option<u32>) {
let nights = nights.filter(|n| *n > 0);
if self.max_nights == nights {
return;
}
self.max_nights = nights;
self.base.request_redraw();
}
fn preset_height(&self) -> u32 {
if self.presets.is_empty() {
0
} else {
PRESET_HEIGHT
}
}
pub fn preset_rect(&self, index: usize) -> Option<Rect> {
if index >= self.presets.len() {
return None;
}
let rect = self.geometry();
let width = rect.width / self.presets.len() as u32;
Some(Rect::new(
rect.x + (index as u32 * width) as i32,
rect.y + 2,
width.saturating_sub(2),
PRESET_HEIGHT.saturating_sub(4),
))
}
pub fn select_preset(&mut self, index: usize) {
let Some(preset) = self.presets.get(index).cloned() else {
return;
};
self.start_date = preset.start;
self.end_date = preset.end;
if let Some((year, month, _)) = preset.start.or(preset.end) {
self.display_year = year;
self.display_month = month;
}
self.emit_range_changed();
self.preset_selected.emit(index);
self.base.request_redraw();
}
pub fn apply_preset(&mut self, preset: &RangePreset) -> Option<usize> {
if let Some(index) = self.presets.iter().position(|own| own.label == preset.label) {
self.select_preset(index);
return Some(index);
}
self.start_date = Some(preset.start);
self.end_date = Some(preset.end);
self.display_year = preset.start.0;
self.display_month = preset.start.1;
self.emit_range_changed();
self.base.request_redraw();
None
}
fn panel_month(&self, panel: usize) -> (i32, u32) {
if panel == 0 {
return (self.display_year, self.display_month);
}
if self.display_month == 12 {
(self.display_year + 1, 1)
} else {
(self.display_year, self.display_month + 1)
}
}
fn panel_count(&self) -> usize {
if self.double_month {
2
} else {
1
}
}
fn nights_allowed(&self, start: (i32, u32, u32), end: (i32, u32, u32)) -> bool {
let Some(nights) = Self::nights_between(start, end) else {
return false;
};
if self.min_nights.is_some_and(|minimum| nights < minimum) {
return false;
}
if self.max_nights.is_some_and(|maximum| nights > maximum) {
return false;
}
true
}
fn nights_between(start: (i32, u32, u32), end: (i32, u32, u32)) -> Option<u32> {
let a = chrono::NaiveDate::from_ymd_opt(start.0, start.1, start.2)?;
let b = chrono::NaiveDate::from_ymd_opt(end.0, end.1, end.2)?;
let nights = (b - a).num_days();
u32::try_from(nights).ok()
}
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 minimum_date(&self) -> Option<(i32, u32, u32)> {
self.minimum_date
}
pub fn set_minimum_date(&mut self, date: Option<(i32, u32, u32)>) {
if self.minimum_date == date {
return;
}
self.minimum_date = date;
self.base.request_redraw();
}
pub fn maximum_date(&self) -> Option<(i32, u32, u32)> {
self.maximum_date
}
pub fn set_maximum_date(&mut self, date: Option<(i32, u32, u32)>) {
if self.maximum_date == date {
return;
}
self.maximum_date = date;
self.base.request_redraw();
}
fn digit_metrics(
&self,
dow_font: &crate::core::Font,
day_font: &crate::core::Font,
context: &RenderContext,
) -> core::cell::RefMut<'_, DigitMetrics> {
let day_key = font_key(day_font);
let header_key = font_key(dow_font);
let mut cache = self.digit_metrics.borrow_mut();
if cache.day_key != Some(day_key) {
cache.day_height = context.measure_text("M", day_font).height;
cache.day_key = Some(day_key);
}
if cache.header_key != Some(header_key) {
cache.header_height = context.measure_text("M", dow_font).height;
cache.header_key = Some(header_key);
}
core::cell::RefMut::map(cache, |cache| cache)
}
pub fn accepts_date(&self, date: (i32, u32, u32)) -> bool {
if self.minimum_date.is_some_and(|minimum| date_to_ordinal(date) < date_to_ordinal(minimum))
{
return false;
}
if self.maximum_date.is_some_and(|maximum| date_to_ordinal(date) > date_to_ordinal(maximum))
{
return false;
}
true
}
pub fn focus_date(&self) -> Option<(i32, u32, u32)> {
self.focus_date
}
fn move_focus(&mut self, days: i64, weeks: i64) {
let Some(current) = self.focus_date.or(self.start_date) else {
if let Some(day) = self.first_selectable_day(self.display_year, self.display_month) {
self.focus_date = Some(day);
self.base.request_redraw();
}
return;
};
let Some(mut naive) = chrono::NaiveDate::from_ymd_opt(current.0, current.1, current.2)
else {
return;
};
let delta = days + weeks * 7;
if delta > 0 {
for _ in 0..delta {
naive = naive.succ_opt().unwrap_or(naive);
}
} else {
for _ in 0..(-delta) {
naive = naive.pred_opt().unwrap_or(naive);
}
}
let next = (naive.year(), naive.month(), naive.day());
if self.focus_date != Some(next) {
self.focus_date = Some(next);
if next.0 != self.display_year || next.1 != self.display_month {
self.display_year = next.0;
self.display_month = next.1;
}
self.base.request_redraw();
}
}
fn first_selectable_day(&self, year: i32, month: u32) -> Option<(i32, u32, u32)> {
for day in 1..=31 {
let date = (year, month, day);
if chrono::NaiveDate::from_ymd_opt(year, month, day).is_none() {
break;
}
if self.accepts_date(date) {
return Some(date);
}
}
None
}
fn apply_day(&mut self, clicked_date: (i32, u32, u32)) {
if !self.accepts_date(clicked_date) {
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);
let (new_start, new_end) = if click_ord > start_ord {
(start, clicked_date)
} else if click_ord < start_ord {
(clicked_date, start)
} else {
self.clear_selection();
return;
};
if !self.nights_allowed(new_start, new_end) {
return;
}
self.start_date = Some(new_start);
self.end_date = Some(new_end);
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();
}
}
}
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 days_in_panel_month(&self, panel: usize) -> u32 {
let (year, month) = self.panel_month(panel);
days_in_month(year, month)
}
#[cfg(test)]
fn first_day_of_week(&self) -> u32 {
day_of_week(self.display_year, self.display_month, 1)
}
fn first_day_of_panel(&self, panel: usize) -> u32 {
let (year, month) = self.panel_month(panel);
day_of_week(year, month, 1)
}
fn date_at_panel_cell(&self, panel: usize, cell_index: usize) -> Option<(i32, u32, u32)> {
let first_dow = self.first_day_of_panel(panel) as usize;
let total_days = self.days_in_panel_month(panel) as usize;
if cell_index < first_dow || cell_index >= first_dow + total_days {
return None;
}
let day = (cell_index - first_dow + 1) as u32;
let (year, month) = self.panel_month(panel);
Some((year, month, day))
}
#[cfg(test)]
fn date_at_cell(&self, cell_index: usize) -> Option<(i32, u32, u32)> {
self.date_at_panel_cell(0, cell_index)
}
fn cell_at_position(&self, pos: Point) -> Option<(usize, usize)> {
for panel in 0..self.panel_count() {
let layout = self.grid_layout_for(panel);
let rel_x = pos.x - layout.grid_left;
let rel_y = pos.y - layout.grid_top;
if rel_x < 0 || rel_y < 0 {
continue;
}
let col = (rel_x as u32) / layout.column_pitch;
let row = (rel_y as u32) / layout.row_pitch;
if col >= 7 || row >= 6 {
continue;
}
let cell_idx = (row as usize) * 7 + (col as usize);
if cell_idx < layout.total_cells {
return Some((panel, cell_idx));
}
}
None
}
fn date_at_position(&self, pos: Point) -> Option<(i32, u32, u32)> {
self.cell_at_position(pos).and_then(|(panel, cell)| self.date_at_panel_cell(panel, cell))
}
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
}
}
#[cfg(test)]
fn grid_layout(&self) -> GridLayout {
self.grid_layout_for(0)
}
fn grid_layout_for(&self, panel: usize) -> GridLayout {
let rect = self.geometry();
let panels = self.panel_count() as u32;
let panel_width = rect.width / panels.max(1);
let panel_x = rect.x + (panel as u32 * panel_width) as i32;
let total_days = self.days_in_panel_month(panel) as usize;
let first_dow = self.first_day_of_panel(panel) as usize;
let rows = ((first_dow + total_days).div_ceil(7) as u32).max(1);
let grid_left = panel_x + GRID_LEFT_INSET;
let preset = self.preset_height() as i32;
let grid_top = rect.y + preset + 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);
let available_width =
(panel_x + panel_width as i32 - grid_left - GRID_RIGHT_INSET).max(1) as u32;
let column_gaps = CELL_SPACING * 6;
let cell_width = (available_width.saturating_sub(column_gaps) / 7).clamp(1, NOMINAL_CELL);
GridLayout {
grid_left,
grid_top,
cell_size,
cell_width,
row_pitch: cell_size + CELL_SPACING,
column_pitch: cell_width + CELL_SPACING,
total_cells: first_dow + total_days,
}
}
}
const PRESET_HEIGHT: u32 = 22;
const HEADER_HEIGHT: u32 = 40;
const DAY_HEADER_HEIGHT: u32 = 20;
const GRID_LEFT_INSET: i32 = 4;
const GRID_RIGHT_INSET: i32 = 4;
const CELL_SPACING: u32 = 2;
const NOMINAL_CELL: u32 = 28;
fn month_name_for(month: u32) -> &'static str {
let idx = (month as usize).saturating_sub(1);
MONTH_NAMES[idx.min(11)]
}
struct GridLayout {
grid_left: i32,
grid_top: i32,
cell_size: u32,
cell_width: u32,
row_pitch: u32,
column_pitch: 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 set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
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!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"range_changed" => {
Some(EventSignalRef::mapped("range_changed", &self.range_changed, |v| {
CapabilityValue::String(format!("{v:?}"))
}))
}
"preset_selected" => {
Some(EventSignalRef::mapped("preset_selected", &self.preset_selected, |v| {
CapabilityValue::UInt(*v as u64)
}))
}
_ => None,
}
}
}
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,
}),
"presets" => Ok(CapabilityValue::Tuple(
self.presets
.iter()
.map(|preset| {
CapabilityValue::String(format!(
"{}|{}|{}",
preset.label,
preset.start.map(format_iso_date).unwrap_or_default(),
preset.end.map(format_iso_date).unwrap_or_default()
))
})
.collect(),
)),
"double_month" => Ok(CapabilityValue::Bool(self.double_month)),
"min_nights" => Ok(CapabilityValue::UInt(self.min_nights.unwrap_or(0) as u64)),
"max_nights" => Ok(CapabilityValue::UInt(self.max_nights.unwrap_or(0) as u64)),
_ => 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(())
}
"presets" => {
let items = expect_string_list(value)?;
let mut presets = Vec::new();
for entry in items {
let mut parts = entry.splitn(3, '|');
let label = parts.next().unwrap_or_default().to_string();
let start =
parts.next().filter(|text| !text.is_empty()).and_then(parse_iso_date);
let end = parts.next().filter(|text| !text.is_empty()).and_then(parse_iso_date);
presets.push(DatePreset { label, start, end });
}
self.set_presets(presets);
Ok(())
}
"double_month" => {
let enabled = match value {
CapabilityValue::Bool(flag) => flag,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_double_month(enabled);
Ok(())
}
"min_nights" => {
let nights = match value {
CapabilityValue::UInt(value) => value,
CapabilityValue::Int(value) if value >= 0 => value as u64,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_min_nights(u32::try_from(nights).ok());
Ok(())
}
"max_nights" => {
let nights = match value {
CapabilityValue::UInt(value) => value,
CapabilityValue::Int(value) if value >= 0 => value as u64,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_max_nights(u32::try_from(nights).ok());
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"start_date",
"end_date",
"presets",
"double_month",
"min_nights",
"max_nights",
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();
let theme = crate::style::resolved_theme_style_shared("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.face_with_gradient(
rect,
bg_color,
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().border_radius.unwrap_or(0),
Color::BLACK,
);
let preset_font = role_font(style, 10.0);
if !self.presets.is_empty() {
for index in 0..self.presets.len() {
let Some(chip) = self.preset_rect(index) else {
continue;
};
context.fill_rounded_rect(chip, 4, surface.blend(&text_color, 0.08));
context.draw_text_fitted_cow(
chip,
self.presets[index].label.as_str(),
&preset_font,
text_color,
HorizontalAlignment::Center,
);
}
}
let header_top = rect.y + self.preset_height() as i32;
let header_font = role_font(style, 14.0).with_bold(true);
let panels = self.panel_count();
let panel_width = rect.width / panels.max(1) as u32;
for panel in 0..panels {
let panel_x = rect.x + (panel as u32 * panel_width) as i32;
let (year, month) = self.panel_month(panel);
let header_band =
Rect::new(panel_x + 20, header_top + 6, panel_width.saturating_sub(40), 18);
let header_text = format!("{} {year}", month_name_for(month));
context.draw_text_fitted_cow(
header_band,
&header_text,
&header_font,
text_color,
HorizontalAlignment::Center,
);
}
let nav_font = role_font(style, 12.0).with_bold(true);
let nav_height = context.measure_text("M", &nav_font).height;
let arrow_side = nav_height.max(1);
crate::widget::draw_icon_at(
context,
Rect::new(rect.x + 6, header_top + 8, arrow_side, arrow_side),
text_color,
IconName::ChevronLeft,
);
crate::widget::draw_icon_at(
context,
Rect::new(rect.x + rect.width as i32 - 20, header_top + 8, arrow_side, arrow_side),
text_color,
IconName::ChevronRight,
);
let dow_font = role_font(style, 9.0);
let day_font = role_font(style, 10.0);
for panel in 0..panels {
draw_month_panel(
self, context, panel, &dow_font, &day_font, surface, text_color, accent, is_enabled,
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_month_panel(
picker: &DateRangePicker,
context: &mut RenderContext,
panel: usize,
dow_font: &crate::core::Font,
day_font: &crate::core::Font,
surface: Color,
text_color: Color,
accent: Color,
is_enabled: bool,
) {
let layout = picker.grid_layout_for(panel);
let grid_left = layout.grid_left;
let grid_top = layout.grid_top;
let cell_size = layout.cell_size;
let cell_width = layout.cell_width;
let row_pitch = layout.row_pitch;
let column_pitch = layout.column_pitch;
let dow_color = surface.blend(&text_color, 0.55);
let metrics = picker.digit_metrics(dow_font, day_font, context);
let dow_height = metrics.header_height;
let margin = TEXT_FIT_MARGIN as i32;
for (i, day_name) in DAY_NAMES.iter().enumerate() {
let cell_x = grid_left + (i as u32 * column_pitch) as i32;
let cell_y = grid_top - DAY_HEADER_HEIGHT as i32;
context.draw_text_fitted_cow(
Rect::new(cell_x - margin, cell_y + 4, cell_width + 2 * margin as u32, dow_height),
day_name,
dow_font,
dow_color,
HorizontalAlignment::Center,
);
}
let mut day_buf = [0u8; 2];
let focus_date = picker.focus_date;
for cell_idx in 0..layout.total_cells {
let Some(date) = picker.date_at_panel_cell(panel, cell_idx) else {
continue;
};
let day = date.2;
let day_text = if day < 10 {
day_buf[0] = b'0' + day as u8;
core::str::from_utf8(&day_buf[..1]).unwrap_or("")
} else {
day_buf[0] = b'0' + (day / 10) as u8;
day_buf[1] = b'0' + (day % 10) as u8;
core::str::from_utf8(&day_buf[..2]).unwrap_or("")
};
let row = cell_idx / 7;
let col = cell_idx % 7;
let cell_x = grid_left + (col as u32 * column_pitch) as i32;
let cell_y = grid_top + (row as u32 * row_pitch) as i32;
let cell_rect = Rect::new(cell_x, cell_y, cell_width, cell_size);
let is_start = picker.start_date == Some(date);
let is_end = picker.end_date == Some(date);
let is_today = is_start || is_end;
let in_range = picker.is_in_range(date);
let is_hover = picker.hover_date == Some(date);
let is_focused = focus_date == Some(date);
let allowed = picker.accepts_date(date);
if allowed && 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 allowed && is_hover && is_enabled {
context.fill_rounded_rect(cell_rect, 4, surface.blend(&text_color, 0.16));
} else if allowed && 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);
}
if is_focused && is_enabled {
context.draw_rect(
Rect::new(
cell_x + 1,
cell_y + 1,
cell_width.saturating_sub(2),
cell_size.saturating_sub(2),
),
accent,
);
}
let day_height = metrics.day_height;
let day_y = cell_y + (cell_size as i32 - day_height as i32) / 2;
let day_color = if !is_enabled || !allowed {
text_color.blend(&surface, 0.55)
} else if is_start || is_end {
surface
} else if in_range {
accent
} else {
text_color
};
context.draw_text_fitted_cow(
Rect::new(
cell_x - TEXT_FIT_MARGIN as i32,
day_y,
cell_width + 2 * TEXT_FIT_MARGIN,
day_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;
}
match event {
Event::MouseMove { pos } => {
let hovered = self.date_at_position(*pos);
if self.hover_date != hovered {
self.hover_date = hovered;
self.base.request_redraw();
}
return;
}
Event::MouseLeave { .. } => {
if self.hover_date.is_some() {
self.hover_date = None;
self.base.request_redraw();
}
return;
}
Event::KeyPress { key, modifiers } => {
let pressed = crate::shortcut::Key::from_key_code(*key);
let modifiers = crate::shortcut::Modifiers::from_event_bits(*modifiers);
match pressed {
Some(crate::shortcut::Key::PageUp) => {
self.previous_month();
return;
}
Some(crate::shortcut::Key::PageDown) => {
self.next_month();
return;
}
Some(crate::shortcut::Key::Left)
if modifiers.contains(crate::shortcut::Modifiers::CTRL) =>
{
self.previous_month();
return;
}
Some(crate::shortcut::Key::Right)
if modifiers.contains(crate::shortcut::Modifiers::CTRL) =>
{
self.next_month();
return;
}
Some(crate::shortcut::Key::Left) => {
self.move_focus(-1, 0);
return;
}
Some(crate::shortcut::Key::Right) => {
self.move_focus(1, 0);
return;
}
Some(crate::shortcut::Key::Up) => {
self.move_focus(0, -1);
return;
}
Some(crate::shortcut::Key::Down) => {
self.move_focus(0, 1);
return;
}
Some(crate::shortcut::Key::Enter) | Some(crate::shortcut::Key::Space) => {
if let Some(day) = self.focus_date {
self.apply_day(day);
}
return;
}
Some(crate::shortcut::Key::Escape) => {
if self.start_date.is_some() && self.end_date.is_none() {
self.clear_selection();
}
return;
}
_ => {}
}
}
_ => {}
}
if let Event::MousePress { pos, button: 1, .. } = event {
let rect = self.geometry();
if !rect.contains_point(*pos) {
return;
}
for index in 0..self.presets.len() {
if self.preset_rect(index).is_some_and(|chip| chip.contains_point(*pos)) {
self.select_preset(index);
return;
}
}
let header_top = rect.y + self.preset_height() as i32;
let header_rect_left = Rect::new(rect.x + 4, header_top + 4, 20, HEADER_HEIGHT);
let header_rect_right =
Rect::new(rect.x + rect.width as i32 - 24, header_top + 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 Some((panel, cell_idx)) = self.cell_at_position(*pos) else {
if self.start_date.is_some() && self.end_date.is_none() {
self.clear_selection();
}
return;
};
let Some(clicked_date) = self.date_at_panel_cell(panel, cell_idx) else {
return;
};
self.focus_date = Some(clicked_date);
self.apply_day(clicked_date);
}
}
}
pub type Date = (i32, u32, u32);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RangePreset {
pub label: String,
pub start: Date,
pub end: Date,
}
impl RangePreset {
pub fn new(label: impl Into<String>, start: Date, end: Date) -> Self {
Self { label: label.into(), start, end }
}
pub fn from_date_preset(preset: &DatePreset) -> Option<Self> {
Some(Self { label: preset.label.clone(), start: preset.start?, end: preset.end? })
}
pub fn to_token(&self) -> String {
format!("{}|{}|{}", self.label, format_iso_date(self.start), format_iso_date(self.end))
}
pub fn from_token(token: &str) -> Option<Self> {
let mut parts = token.splitn(3, '|');
let label = parts.next()?.to_string();
let start = parse_iso_date(parts.next()?)?;
let end = parse_iso_date(parts.next()?)?;
Some(Self { label, start, end })
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PresetBarOrientation {
#[default]
Horizontal,
Vertical,
}
impl PresetBarOrientation {
pub fn as_str(self) -> &'static str {
match self {
Self::Horizontal => "horizontal",
Self::Vertical => "vertical",
}
}
pub fn from_token(token: &str) -> Option<Self> {
match token {
"horizontal" | "row" => Some(Self::Horizontal),
"vertical" | "column" => Some(Self::Vertical),
_ => None,
}
}
}
const CHIP_HEIGHT: u32 = 24;
const COMPACT_CHIP_HEIGHT: u32 = 18;
pub struct RangePresetBar {
base: BaseWidget,
presets: Vec<RangePreset>,
orientation: PresetBarOrientation,
compact: bool,
selected: Option<usize>,
hovered: Option<usize>,
cursor: usize,
pub preset_selected: Signal1<(Date, Date)>,
}
impl RangePresetBar {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::RangePresetBar, geometry, "RangePresetBar"),
presets: Vec::new(),
orientation: PresetBarOrientation::Horizontal,
compact: false,
selected: None,
hovered: None,
cursor: 0,
preset_selected: Signal1::new(),
}
}
pub fn presets(&self) -> &[RangePreset] {
&self.presets
}
pub fn set_presets(&mut self, presets: Vec<RangePreset>) {
let active_label = self
.selected
.and_then(|index| self.presets.get(index))
.map(|preset| preset.label.clone());
self.presets = presets;
self.selected = active_label
.and_then(|label| self.presets.iter().position(|preset| preset.label == label));
self.cursor = self.cursor.min(self.presets.len().saturating_sub(1));
self.base.request_layout();
self.base.request_redraw();
}
pub fn orientation(&self) -> PresetBarOrientation {
self.orientation
}
pub fn set_orientation(&mut self, orientation: PresetBarOrientation) {
if self.orientation == orientation {
return;
}
self.orientation = orientation;
self.base.request_layout();
self.base.request_redraw();
}
pub fn compact(&self) -> bool {
self.compact
}
pub fn set_compact(&mut self, compact: bool) {
if self.compact == compact {
return;
}
self.compact = compact;
self.base.request_redraw();
}
pub fn selected_label(&self) -> Option<&str> {
self.selected.and_then(|index| self.presets.get(index)).map(|preset| preset.label.as_str())
}
pub fn selected_range(&self) -> Option<(Date, Date)> {
self.selected
.and_then(|index| self.presets.get(index))
.map(|preset| (preset.start, preset.end))
}
pub fn select(&mut self, label: &str) -> bool {
let Some(index) = self.presets.iter().position(|preset| preset.label == label) else {
return false;
};
self.select_index(index);
true
}
pub fn select_index(&mut self, index: usize) {
let Some(preset) = self.presets.get(index) else {
return;
};
let range = (preset.start, preset.end);
let changed = self.selected != Some(index);
self.selected = Some(index);
self.cursor = index;
if changed {
self.preset_selected.emit(range);
}
self.base.request_redraw();
}
pub fn chip_rect(&self, index: usize) -> Option<Rect> {
if index >= self.presets.len() {
return None;
}
let rect = self.geometry();
let count = self.presets.len() as u32;
match self.orientation {
PresetBarOrientation::Horizontal => {
let width = rect.width / count.max(1);
let height = if self.compact {
COMPACT_CHIP_HEIGHT.min(rect.height)
} else {
CHIP_HEIGHT.min(rect.height)
};
Some(Rect::new(
rect.x + (index as u32 * width) as i32,
rect.y,
width.saturating_sub(2),
height,
))
}
PresetBarOrientation::Vertical => {
let height = rect.height / count.max(1);
Some(Rect::new(
rect.x,
rect.y + (index as u32 * height) as i32,
rect.width,
height.saturating_sub(2),
))
}
}
}
fn chip_at(&self, pos: Point) -> Option<usize> {
(0..self.presets.len())
.find(|index| self.chip_rect(*index).is_some_and(|chip| chip.contains_point(pos)))
}
fn move_cursor(&mut self, delta: isize) {
if self.presets.is_empty() {
return;
}
let last = self.presets.len() - 1;
let next = (self.cursor as isize + delta).clamp(0, last as isize) as usize;
self.select_index(next);
}
}
impl Widget for RangePresetBar {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
match self.orientation {
PresetBarOrientation::Horizontal => crate::core::Size::new(320, CHIP_HEIGHT),
PresetBarOrientation::Vertical => crate::core::Size::new(160, 160),
}
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"preset_selected" => {
Some(EventSignalRef::mapped("preset_selected", &self.preset_selected, |range| {
CapabilityValue::Tuple(crate::compat::Vec::from([
CapabilityValue::String(format_iso_date(range.0)),
CapabilityValue::String(format_iso_date(range.1)),
]))
}))
}
_ => None,
}
}
}
impl WidgetProperties for RangePresetBar {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"presets" => Ok(CapabilityValue::Tuple(
self.presets
.iter()
.map(|preset| CapabilityValue::String(preset.to_token()))
.collect(),
)),
"orientation" => Ok(CapabilityValue::String(self.orientation.as_str().to_string())),
"compact" => Ok(CapabilityValue::Bool(self.compact)),
"selected_label" => Ok(match self.selected_label() {
Some(label) => CapabilityValue::String(label.to_string()),
None => CapabilityValue::Null,
}),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"presets" => {
let items = expect_string_list(value)?;
let mut presets = Vec::with_capacity(items.len());
for item in items {
let preset = RangePreset::from_token(&item)
.ok_or(CapabilityAccessError::TypeMismatch)?;
presets.push(preset);
}
self.set_presets(presets);
Ok(())
}
"orientation" => {
let token = expect_string(value)?;
let orientation = PresetBarOrientation::from_token(&token)
.ok_or(CapabilityAccessError::TypeMismatch)?;
self.set_orientation(orientation);
Ok(())
}
"compact" => {
let compact = match value {
CapabilityValue::Bool(flag) => flag,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_compact(compact);
Ok(())
}
"selected_label" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"presets",
"orientation",
"compact",
"selected_label",
BASE_PROPERTY_NAMES
]
}
}
impl EventHandler for RangePresetBar {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button: 1, .. } => {
if let Some(index) = self.chip_at(*pos) {
self.select_index(index);
}
}
Event::MouseMove { pos } => {
let hovered = self.chip_at(*pos);
if hovered != self.hovered {
self.hovered = hovered;
self.base.request_redraw();
}
}
Event::KeyPress { key, .. } => match *key {
37 | 38 => self.move_cursor(-1),
39 | 40 => self.move_cursor(1),
13 if !self.presets.is_empty() => {
self.select_index(self.cursor.min(self.presets.len() - 1));
}
_ => { }
},
_ => {}
}
}
}
impl Draw for RangePresetBar {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("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 accent =
crate::style::semantic_color(crate::style::SemanticColor::Info).unwrap_or(Color::BLUE);
let font = role_font(style, 10.0);
context.face_with_gradient(
rect,
surface,
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().border_radius.unwrap_or(0),
Color::BLACK,
);
for index in 0..self.presets.len() {
let Some(chip) = self.chip_rect(index) else {
continue;
};
let active = self.selected == Some(index);
let fill = if active {
accent.blend(&surface, 0.25)
} else if self.hovered == Some(index) {
surface.blend(&text_color, 0.08)
} else {
surface.blend(&text_color, 0.03)
};
context.fill_rounded_rect(chip, 4, fill);
if active {
crate::widget::draw::stroke_surface_border(context, chip, 4, accent, 1);
}
let label = crate::widget::text_utils::elide_end(
&self.presets[index].label,
(chip.width as usize / 7).max(1),
);
context.draw_text(
Point::new(chip.x + 6, chip.y + 4),
&label,
&font,
if active { text_color } else { text_color.blend(&surface, 0.25) },
HorizontalAlignment::Left,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::svg::render_to_svg;
fn make_picker() -> DateRangePicker {
let mut picker = DateRangePicker::new(Rect::new(0, 0, 240, 260));
picker.display_year = 2025;
picker.display_month = 1;
picker
}
#[test]
fn date_range_picker_default_creation() {
let picker = DateRangePicker::new(Rect::new(0, 0, 240, 260));
let today = chrono::Local::now().date_naive();
assert_eq!(picker.kind(), WidgetKind::DateRangePicker);
assert!(picker.start_date().is_none());
assert!(picker.end_date().is_none());
assert_eq!(picker.display_year(), today.year());
assert_eq!(picker.display_month(), today.month());
}
#[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();
picker.set_current_month();
let today = chrono::Local::now().date_naive();
assert_eq!(picker.display_month(), today.month());
picker.previous_month();
let (year, month) = if today.month() == 1 {
(today.year() - 1, 12)
} else {
(today.year(), today.month() - 1)
};
assert_eq!(picker.display_month(), month);
assert_eq!(picker.display_year(), year);
picker.next_month();
assert_eq!(picker.display_month(), today.month());
assert_eq!(picker.display_year(), today.year());
}
#[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>"));
}
fn cell_centre(picker: &DateRangePicker, col: u32, row: u32) -> Point {
let layout = picker.grid_layout();
Point::new(
layout.grid_left + (col * layout.column_pitch) as i32 + layout.cell_width as i32 / 2,
layout.grid_top + (row * layout.row_pitch) as i32 + layout.cell_size as i32 / 2,
)
}
#[test]
fn january_2025_starts_in_the_wednesday_column() {
assert_eq!(day_of_week(2025, 1, 1), 3);
let picker = make_picker();
assert_eq!(picker.first_day_of_week(), 3);
assert_eq!(picker.date_at_cell(2), None);
assert_eq!(picker.date_at_cell(3), Some((2025, 1, 1)));
assert_eq!(picker.date_at_cell(9), Some((2025, 1, 7)));
}
#[test]
fn a_click_selects_the_day_painted_in_that_cell() {
let picker = make_picker();
let offset = picker.first_day_of_week();
for day in [1u32, 7, 15, 22, 31] {
let index = offset + day - 1;
let (col, row) = (index % 7, index / 7);
let mut clicked = make_picker();
clicked.handle_event(&Event::MousePress {
pos: cell_centre(&picker, col, row),
button: 1,
modifiers: 0,
});
assert_eq!(
clicked.start_date(),
Some((2025, 1, day)),
"day {day} drawn at column {col}, row {row} was hit-tested as {:?}",
clicked.start_date()
);
assert!(clicked.end_date().is_none());
}
}
#[test]
fn hover_tracks_the_day_under_the_pointer_and_clears_on_leave() {
let mut picker = make_picker();
let offset = picker.first_day_of_week();
let index = offset + 14; let (col, row) = (index % 7, index / 7);
picker.handle_event(&Event::MouseMove { pos: cell_centre(&picker, col, row) });
assert_eq!(
picker.hover_date,
Some((2025, 1, 15)),
"the cell under the pointer must be the highlighted date"
);
picker.handle_event(&Event::MouseLeave { pos: Point::new(0, 0) });
assert_eq!(picker.hover_date, None, "leaving the control clears the preview");
}
#[test]
fn date_range_picker_mouse_press_sets_start() {
let mut picker = make_picker();
picker.handle_event(&Event::MousePress {
pos: cell_centre(&picker, 3, 2),
button: 1,
modifiers: 0,
});
assert_eq!(picker.start_date(), Some((2025, 1, 15)));
assert!(picker.end_date().is_none());
}
#[test]
fn a_click_in_a_leading_blank_does_not_invent_a_date() {
let mut picker = make_picker();
picker.handle_event(&Event::MousePress {
pos: cell_centre(&picker, 0, 0),
button: 1,
modifiers: 0,
});
assert_eq!(picker.start_date(), 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,
modifiers: 0,
});
assert!(picker.start_date().is_none());
}
#[test]
fn presets_double_month_and_night_bounds_are_live() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let mut picker = make_picker();
picker.set_presets(vec![DatePreset::new("Week", (2025, 1, 1), (2025, 1, 7))]);
let hits = Arc::new(AtomicUsize::new(0));
let sink = Arc::clone(&hits);
picker.preset_selected.connect(move |_| {
sink.fetch_add(1, Ordering::SeqCst);
});
let chip = picker.preset_rect(0).expect("the preset chip exists");
picker.handle_event(&Event::MousePress {
pos: Point::new(chip.x + 2, chip.y + 2),
button: 1,
modifiers: 0,
});
assert_eq!(picker.start_date(), Some((2025, 1, 1)));
assert_eq!(picker.end_date(), Some((2025, 1, 7)));
assert_eq!(hits.load(Ordering::SeqCst), 1);
picker.set_double_month(true);
assert!(picker.double_month());
let svg = render_to_svg(&mut picker);
assert!(svg.starts_with("<svg"));
assert!(picker.nights_allowed((2025, 1, 1), (2025, 1, 2)));
picker.set_min_nights(Some(2));
assert!(!picker.nights_allowed((2025, 1, 1), (2025, 1, 2)));
assert!(picker.nights_allowed((2025, 1, 1), (2025, 1, 3)));
picker.set_max_nights(Some(3));
assert!(!picker.nights_allowed((2025, 1, 1), (2025, 1, 5)));
}
#[test]
fn a_range_preset_applies_to_the_picker() {
let mut picker = make_picker();
picker.set_presets(vec![DatePreset::new("Week", (2025, 1, 1), (2025, 1, 7))]);
let reported = std::sync::Arc::new(std::sync::Mutex::new(Vec::<usize>::new()));
let sink = reported.clone();
picker.preset_selected.connect(move |index| {
if let Ok(mut guard) = sink.lock() {
guard.push(*index);
}
});
let known = RangePreset::new("Week", (2025, 1, 1), (2025, 1, 7));
assert_eq!(picker.apply_preset(&known), Some(0));
assert_eq!(picker.start_date(), Some((2025, 1, 1)));
assert_eq!(picker.end_date(), Some((2025, 1, 7)));
assert_eq!(*reported.lock().expect("reported lock"), vec![0]);
let unknown = RangePreset::new("Quarter", (2025, 4, 1), (2025, 6, 30));
assert_eq!(picker.apply_preset(&unknown), None);
assert_eq!(picker.start_date(), Some((2025, 4, 1)));
assert_eq!(picker.end_date(), Some((2025, 6, 30)));
}
#[test]
fn preset_bar_emits_the_range_it_applies() {
let mut bar = RangePresetBar::new(Rect::new(0, 0, 300, 24));
bar.set_presets(vec![
RangePreset::new("Today", (2025, 1, 1), (2025, 1, 1)),
RangePreset::new("Week", (2025, 1, 1), (2025, 1, 7)),
]);
let ranges = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = ranges.clone();
bar.preset_selected.connect(move |range| {
if let Ok(mut guard) = sink.lock() {
guard.push(*range);
}
});
assert!(bar.select("Week"));
assert!(!bar.select("Missing"));
assert_eq!(bar.selected_label(), Some("Week"));
assert_eq!(bar.selected_range(), Some(((2025, 1, 1), (2025, 1, 7))));
assert_eq!(*ranges.lock().expect("ranges lock"), vec![((2025, 1, 1), (2025, 1, 7))]);
let chip = bar.chip_rect(0).expect("chip 0");
bar.handle_event(&Event::mouse_press(chip.x + 2, chip.y + 2, 1));
assert_eq!(bar.selected_label(), Some("Today"));
}
#[test]
fn preset_bar_properties_drive_layout_and_round_trip() {
let mut bar = RangePresetBar::new(Rect::new(0, 0, 300, 100));
let presets = CapabilityValue::Tuple(vec![
CapabilityValue::String("Today|2025-01-01|2025-01-01".to_string()),
CapabilityValue::String("Week|2025-01-01|2025-01-07".to_string()),
]);
bar.set("presets", presets.clone()).expect("presets writable");
assert_eq!(bar.get("presets"), Ok(presets));
bar.set("orientation", CapabilityValue::String("vertical".to_string()))
.expect("orientation writable");
assert_eq!(bar.orientation(), PresetBarOrientation::Vertical);
assert_eq!(bar.chip_rect(1).expect("chip 1").y, 50);
bar.set("compact", CapabilityValue::Bool(true)).expect("compact writable");
assert!(bar.compact());
assert_eq!(bar.get("selected_label"), Ok(CapabilityValue::Null));
assert_eq!(
bar.set("selected_label", CapabilityValue::String("Week".to_string())),
Err(CapabilityAccessError::ReadOnlyProperty)
);
}
#[test]
fn go_to_today_selects_the_wall_clock_day() {
let mut picker = make_picker();
picker.go_to_today();
let today = chrono::Local::now().date_naive();
assert_eq!(picker.display_year(), today.year());
assert_eq!(picker.display_month(), today.month());
assert_eq!(picker.start_date(), Some((today.year(), today.month(), today.day())));
}
#[test]
fn the_date_bounds_block_a_selection() {
let mut picker = DateRangePicker::new(Rect::new(0, 0, 240, 260));
picker.set_minimum_date(Some((2025, 6, 10)));
picker.set_maximum_date(Some((2025, 6, 20)));
assert!(!picker.accepts_date((2025, 6, 9)));
assert!(picker.accepts_date((2025, 6, 10)));
assert!(picker.accepts_date((2025, 6, 20)));
assert!(!picker.accepts_date((2025, 6, 21)));
picker.apply_day((2025, 6, 5));
assert_eq!(picker.start_date(), None);
picker.apply_day((2025, 6, 12));
assert_eq!(picker.start_date(), Some((2025, 6, 12)));
}
#[test]
fn the_keyboard_walks_the_grid_and_commits() {
let mut picker = DateRangePicker::new(Rect::new(0, 0, 240, 260));
picker.set_display_page(2025, 6);
picker.set_start_date(Some((2025, 6, 10)));
picker.handle_event(&Event::key_press(crate::event::key_codes::RIGHT, 0));
assert_eq!(picker.focus_date(), Some((2025, 6, 11)));
picker.handle_event(&Event::key_press(crate::event::key_codes::DOWN, 0));
assert_eq!(picker.focus_date(), Some((2025, 6, 18)));
picker.handle_event(&Event::key_press(crate::event::key_codes::ENTER, 0));
assert_eq!(picker.end_date(), Some((2025, 6, 18)));
picker.set_end_date(None);
picker.handle_event(&Event::key_press(crate::event::key_codes::ESCAPE, 0));
assert_eq!(picker.start_date(), None);
}
#[test]
fn the_keyboard_flips_the_page_across_a_month_boundary() {
let mut picker = DateRangePicker::new(Rect::new(0, 0, 240, 260));
picker.set_display_page(2025, 6);
picker.set_start_date(Some((2025, 6, 30)));
picker.handle_event(&Event::key_press(crate::event::key_codes::RIGHT, 0));
assert_eq!(picker.focus_date(), Some((2025, 7, 1)));
assert_eq!(picker.display_month(), 7);
}
}