use crate::core::{Color, Font, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
const MONTH_NAMES: &[&str] =
&["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
29
} else {
28
}
}
_ => 30,
}
}
pub struct MobileDatePicker {
base: BaseWidget,
year: i32,
month: u32,
day: u32,
pub date_changed: Signal1<String>,
}
impl MobileDatePicker {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::MobileDatePicker, geometry, "MobileDatePicker"),
year: 2025,
month: 1,
day: 1,
date_changed: Signal1::new(),
}
}
pub fn set_date(&mut self, year: i32, month: u32, day: u32) {
let clamped_month = month.clamp(1, 12);
let max_day = days_in_month(year, clamped_month);
let clamped_day = day.clamp(1, max_day);
if self.year != year || self.month != clamped_month || self.day != clamped_day {
self.year = year;
self.month = clamped_month;
self.day = clamped_day;
self.date_changed.emit(self.date_string());
self.base.request_redraw();
}
}
pub fn year(&self) -> i32 {
self.year
}
pub fn month(&self) -> u32 {
self.month
}
pub fn day(&self) -> u32 {
self.day
}
pub fn date_string(&self) -> String {
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
fn column_at(&self, x: i32) -> usize {
let rect = self.geometry();
let col_width = rect.width / 3;
if x < rect.x {
return 0;
}
let rel_x = (x - rect.x) as u32;
let col = rel_x / col_width;
(col as usize).min(2)
}
}
impl Widget for MobileDatePicker {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
}
impl Draw for MobileDatePicker {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let is_enabled = self.base.is_enabled();
let bg_color = if is_enabled {
Color::rgba(245, 245, 250, 255)
} else {
Color::rgba(230, 230, 235, 200)
};
context.fill_rect(rect, bg_color);
let col_width = rect.width / 3;
let row_height = rect.height / 5;
let font_size = (row_height as f32 * 0.38).clamp(10.0, 15.0);
let font = Font::new("sans-serif", font_size, false, false);
let arrow_font = Font::new("sans-serif", (font_size * 1.3).max(12.0), true, false);
let year_items: Vec<String> =
(self.year - 2..=self.year + 2).map(|y| y.to_string()).collect();
let year_offset: i32 = 2;
let month_items: Vec<String> = MONTH_NAMES.iter().map(|&n| n.to_string()).collect();
let month_offset = (self.month as usize - 1) as i32;
let max_day = days_in_month(self.year, self.month);
let day_items: Vec<String> = (1..=max_day).map(|d| format!("{:02}", d)).collect();
let day_offset = (self.day as usize - 1) as i32;
let columns: [(i32, Vec<String>, &str); 3] = [
(year_offset, year_items, "Year"),
(month_offset, month_items, "Month"),
(day_offset, day_items, "Day"),
];
for (col_idx, (sel_offset, items, _label)) in columns.iter().enumerate() {
let col_x = rect.x + (col_idx as u32 * col_width) as i32;
if col_idx > 0 {
context.draw_rect_stroke(
Rect::new(col_x, rect.y, 1, rect.height),
Color::rgba(200, 200, 210, 255),
1,
);
}
let highlight_y = rect.y + 2 * row_height as i32;
let highlight_rect = Rect::new(col_x + 4, highlight_y, col_width - 8, row_height);
context.fill_rounded_rect(highlight_rect, 6, Color::rgba(60, 120, 240, 50));
for row in 0..5 {
let item_idx = row + (sel_offset - 2);
if item_idx < 0 || item_idx >= items.len() as i32 {
continue;
}
let text = &items[item_idx as usize];
let is_selected = row == 2;
let item_y = rect.y + (row as u32 * row_height) as i32;
let metrics = context.measure_text(text, &font);
let text_x = col_x + (col_width as i32 - metrics.width as i32) / 2;
let text_y = item_y
+ (row_height as i32 - metrics.height as i32) / 2
+ metrics.ascent as i32;
let text_color = if !is_enabled {
Color::rgba(160, 160, 170, 200)
} else if is_selected {
Color::rgba(40, 70, 190, 255)
} else {
Color::rgba(130, 130, 150, 210)
};
context.draw_text(Point::new(text_x, text_y), text, &font, text_color);
}
let arrow_color = if is_enabled {
Color::rgba(80, 80, 100, 220)
} else {
Color::rgba(160, 160, 170, 150)
};
let up_y = rect.y + 2;
context.draw_text(
Point::new(col_x + (col_width as i32 - 8) / 2, up_y),
"^",
&arrow_font,
arrow_color,
);
let down_y = rect.y + rect.height as i32 - row_height as i32 + 2;
context.draw_text(
Point::new(col_x + (col_width as i32 - 8) / 2, down_y),
"v",
&arrow_font,
arrow_color,
);
}
}
}
impl EventHandler for MobileDatePicker {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::Wheel { delta, .. } => {
if delta.y > 0 {
self.set_date(
self.year + 1,
self.month,
self.day.min(days_in_month(self.year + 1, self.month)),
);
} else if delta.y < 0 {
self.set_date(
self.year - 1,
self.month,
self.day.min(days_in_month(self.year - 1, self.month)),
);
}
}
Event::MousePress { pos, button: 1 } => {
let col = self.column_at(pos.x);
let rect = self.geometry();
let row_height = rect.height / 5;
let rel_y = pos.y - rect.y;
let row = rel_y / row_height as i32;
let increment = row <= 2;
match col {
0 => {
let new_year = if increment { self.year + 1 } else { self.year - 1 };
let max_day = days_in_month(new_year, self.month);
let clamped_day = self.day.min(max_day);
self.set_date(new_year, self.month, clamped_day);
}
1 => {
let new_month = if increment {
(self.month as i32 + 1).clamp(1, 12) as u32
} else {
(self.month as i32 - 1).clamp(1, 12) as u32
};
let max_day = days_in_month(self.year, new_month);
let clamped_day = self.day.min(max_day);
self.set_date(self.year, new_month, clamped_day);
}
2 => {
let max_days = days_in_month(self.year, self.month) as i32;
let new_day = if increment {
(self.day as i32 + 1).clamp(1, max_days) as u32
} else {
(self.day as i32 - 1).clamp(1, max_days) as u32
};
self.set_date(self.year, self.month, new_day);
}
_ => {}
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::svg::render_to_svg;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
fn make_picker() -> MobileDatePicker {
MobileDatePicker::new(Rect::new(0, 0, 240, 200))
}
#[test]
fn picker_default_creation() {
let picker = make_picker();
assert_eq!(picker.kind(), WidgetKind::MobileDatePicker);
assert_eq!(picker.year(), 2025);
assert_eq!(picker.month(), 1);
assert_eq!(picker.day(), 1);
assert_eq!(picker.date_string(), "2025-01-01");
assert!(picker.is_visible());
assert!(picker.is_enabled());
assert_eq!(picker.geometry(), Rect::new(0, 0, 240, 200));
}
#[test]
fn picker_set_date_changes_values() {
let mut picker = make_picker();
picker.set_date(2026, 12, 25);
assert_eq!(picker.year(), 2026);
assert_eq!(picker.month(), 12);
assert_eq!(picker.day(), 25);
assert_eq!(picker.date_string(), "2026-12-25");
}
#[test]
fn picker_set_date_clamps_month_out_of_range() {
let mut picker = make_picker();
picker.set_date(2025, 0, 1);
assert_eq!(picker.month(), 1);
picker.set_date(2025, 13, 1);
assert_eq!(picker.month(), 12);
}
#[test]
fn picker_set_date_clamps_day_out_of_range() {
let mut picker = make_picker();
picker.set_date(2025, 1, 35);
assert_eq!(picker.day(), 31);
assert_eq!(picker.date_string(), "2025-01-31");
picker.set_date(2025, 2, 30);
assert_eq!(picker.day(), 28);
assert_eq!(picker.date_string(), "2025-02-28");
}
#[test]
fn picker_set_date_handles_leap_year() {
let mut picker = make_picker();
picker.set_date(2024, 2, 29);
assert_eq!(picker.day(), 29);
assert_eq!(picker.date_string(), "2024-02-29");
picker.set_date(2025, 2, 29);
assert_eq!(picker.day(), 28);
assert_eq!(picker.date_string(), "2025-02-28");
}
#[test]
fn picker_set_date_same_value_no_emit() {
let mut picker = make_picker();
let emitted = Arc::new(AtomicBool::new(false));
let e = emitted.clone();
picker.date_changed.connect(move |_val: Arc<String>| {
e.store(true, Ordering::SeqCst);
});
picker.set_date(2025, 1, 1);
assert!(!emitted.load(Ordering::SeqCst));
picker.set_date(2025, 3, 15);
assert!(emitted.load(Ordering::SeqCst));
}
#[test]
fn picker_set_date_emits_signal() {
let mut picker = make_picker();
let captured = Arc::new(std::sync::Mutex::new(None));
let c = captured.clone();
picker.date_changed.connect(move |val: Arc<String>| {
*c.lock().unwrap() = Some(val.to_string());
});
picker.set_date(2026, 7, 4);
let result = captured.lock().unwrap().clone();
assert_eq!(result, Some("2026-07-04".to_string()));
}
#[test]
fn picker_individual_accessors() {
let picker = make_picker();
assert_eq!(picker.year(), 2025);
assert_eq!(picker.month(), 1);
assert_eq!(picker.day(), 1);
}
#[test]
fn picker_date_string_format() {
let mut picker = make_picker();
assert_eq!(picker.date_string(), "2025-01-01");
picker.set_date(1999, 12, 31);
assert_eq!(picker.date_string(), "1999-12-31");
picker.set_date(2024, 2, 9);
assert_eq!(picker.date_string(), "2024-02-09");
}
#[test]
fn picker_column_at_returns_correct_column() {
let picker = make_picker();
assert_eq!(picker.column_at(10), 0); assert_eq!(picker.column_at(85), 1); assert_eq!(picker.column_at(170), 2); assert_eq!(picker.column_at(250), 2); assert_eq!(picker.column_at(-5), 0); }
#[test]
fn picker_mouse_press_year_column() {
let mut picker = make_picker();
picker.handle_event(&Event::MousePress { pos: Point::new(20, 30), button: 1 });
assert_eq!(picker.year(), 2026);
picker.handle_event(&Event::MousePress { pos: Point::new(20, 150), button: 1 });
assert_eq!(picker.year(), 2025);
}
#[test]
fn picker_mouse_press_month_column() {
let mut picker = make_picker();
picker.handle_event(&Event::MousePress { pos: Point::new(100, 30), button: 1 });
assert_eq!(picker.month(), 2);
picker.handle_event(&Event::MousePress { pos: Point::new(100, 150), button: 1 });
assert_eq!(picker.month(), 1);
}
#[test]
fn picker_mouse_press_day_column() {
let mut picker = make_picker();
picker.handle_event(&Event::MousePress { pos: Point::new(180, 30), button: 1 });
assert_eq!(picker.day(), 2);
picker.handle_event(&Event::MousePress { pos: Point::new(180, 150), button: 1 });
assert_eq!(picker.day(), 1);
}
#[test]
fn picker_wheel_scrolls_year() {
let mut picker = make_picker();
picker.handle_event(&Event::Wheel { delta: Point::new(0, 1), modifiers: 0 });
assert_eq!(picker.year(), 2026);
picker.handle_event(&Event::Wheel { delta: Point::new(0, -1), modifiers: 0 });
assert_eq!(picker.year(), 2025);
}
#[test]
fn picker_disabled_blocks_events() {
let mut picker = make_picker();
picker.set_enabled(false);
picker.handle_event(&Event::MousePress { pos: Point::new(20, 30), button: 1 });
assert_eq!(picker.year(), 2025);
picker.handle_event(&Event::Wheel { delta: Point::new(0, 1), modifiers: 0 });
assert_eq!(picker.year(), 2025);
}
#[test]
fn picker_other_button_noop() {
let mut picker = make_picker();
picker.handle_event(&Event::MousePress { pos: Point::new(20, 30), button: 2 });
assert_eq!(picker.year(), 2025);
}
#[test]
fn picker_day_clamp_on_year_change() {
let mut picker = make_picker();
picker.set_date(2024, 2, 29);
assert_eq!(picker.day(), 29);
picker.handle_event(&Event::Wheel { delta: Point::new(0, 1), modifiers: 0 });
assert_eq!(picker.year(), 2025);
assert_eq!(picker.day(), 28);
}
#[test]
fn picker_day_wraps_correctly() {
let mut picker = make_picker();
picker.set_date(2025, 1, 31);
picker.handle_event(&Event::MousePress {
pos: Point::new(100, 30), button: 1,
});
assert_eq!(picker.month(), 2);
assert_eq!(picker.day(), 28);
}
#[test]
fn picker_svg_output() {
let mut picker = make_picker();
picker.set_date(2026, 7, 4);
let svg = render_to_svg(&mut picker);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
assert!(svg.contains("width=\"240\""));
assert!(svg.contains("height=\"200\""));
}
#[test]
fn picker_svg_output_disabled() {
let mut picker = make_picker();
picker.set_enabled(false);
let svg = render_to_svg(&mut picker);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn picker_day_edge_cases() {
let mut picker = make_picker();
picker.set_date(2025, 1, 1);
picker.handle_event(&Event::MousePress {
pos: Point::new(180, 150), button: 1,
});
assert_eq!(picker.day(), 1);
picker.set_date(2025, 1, 31);
picker.handle_event(&Event::MousePress {
pos: Point::new(180, 30), button: 1,
});
assert_eq!(picker.day(), 31);
}
#[test]
fn picker_month_edge_cases() {
let mut picker = make_picker();
picker.set_date(2025, 1, 15);
picker.handle_event(&Event::MousePress {
pos: Point::new(100, 150), button: 1,
});
assert_eq!(picker.month(), 1);
picker.set_date(2025, 12, 15);
picker.handle_event(&Event::MousePress {
pos: Point::new(100, 30), button: 1,
});
assert_eq!(picker.month(), 12);
}
}