day_spec/controls/datetime.rs
1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4pub const DATE_KIND: &str = "day.piece.datepicker";
5pub const TIME_KIND: &str = "day.piece.timepicker";
6
7// ---------------------------------------------------------------------------
8// Values: proleptic-Gregorian civil date + wall-clock time. No chrono dependency: ISO-8601
9// strings and epoch days / seconds-of-day are the interchange forms across native boundaries.
10// ---------------------------------------------------------------------------
11
12/// A civil (zoneless) calendar date, proleptic Gregorian. Ordered chronologically; `Display` and
13/// [`DayDate::parse_iso`] speak ISO-8601 (`YYYY-MM-DD`).
14#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub struct DayDate {
16 pub year: i32,
17 pub month: u8,
18 pub day: u8,
19}
20
21impl Default for DayDate {
22 fn default() -> Self {
23 DayDate {
24 year: 1970,
25 month: 1,
26 day: 1,
27 }
28 }
29}
30
31/// Seconds since the Unix epoch, from whatever clock this target has.
32///
33/// `SystemTime::now()` traps on `wasm32-unknown-unknown`: the target has no clock, and the trap
34/// is not a panic that can be caught, it aborts the module. So a `DayDate::today()` anywhere on
35/// the startup path took the whole web app down with `RuntimeError: Unreachable code`. The web
36/// build reads the host's clock through the same JS shim day-toolkit-dom imports everything else from.
37fn now_epoch_secs() -> i64 {
38 #[cfg(target_arch = "wasm32")]
39 {
40 // SAFETY: the shim exports this for every web-dom app; a build that links this piece and
41 // an older shim fails at instantiation with a LinkError naming the missing import, which
42 // is the loud failure rather than the silent one.
43 unsafe { day_datetime_now_secs() as i64 }
44 }
45 #[cfg(not(target_arch = "wasm32"))]
46 {
47 std::time::SystemTime::now()
48 .duration_since(std::time::UNIX_EPOCH)
49 .map(|d| d.as_secs() as i64)
50 .unwrap_or(0)
51 }
52}
53
54// The attribute makes this a wasm import from the instantiation's `env` object (shim.js supplies
55// it) rather than a link-time-resolved symbol; without it rust-lld reports it undefined.
56#[cfg(target_arch = "wasm32")]
57#[link(wasm_import_module = "env")]
58unsafe extern "C" {
59 /// Seconds since the Unix epoch (fractional), from the browser's clock.
60 fn day_datetime_now_secs() -> f64;
61}
62
63impl DayDate {
64 /// A validated date (`month` 1–12, `day` within the month, leap-aware); `None` otherwise.
65 pub fn new(year: i32, month: u8, day: u8) -> Option<Self> {
66 if (1..=12).contains(&month) && day >= 1 && day <= days_in_month(year, month) {
67 Some(DayDate { year, month, day })
68 } else {
69 None
70 }
71 }
72
73 /// Today's date derived from the system clock in UTC (Day carries no time-zone database; an
74 /// app that must roll the date at local midnight should compute it with its own zone source).
75 pub fn today() -> Self {
76 Self::from_epoch_days(now_epoch_secs().div_euclid(86_400))
77 }
78
79 /// Days since 1970-01-01 (negative before). The numeric interchange form across native
80 /// boundaries (`Event::Custom.num`).
81 pub fn to_epoch_days(self) -> i64 {
82 // Howard Hinnant's days_from_civil.
83 let y = self.year as i64 - if self.month <= 2 { 1 } else { 0 };
84 let era = if y >= 0 { y } else { y - 399 } / 400;
85 let yoe = y - era * 400;
86 let m = self.month as i64;
87 let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + self.day as i64 - 1;
88 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
89 era * 146097 + doe - 719468
90 }
91
92 /// The date `days` after 1970-01-01 (inverse of [`DayDate::to_epoch_days`]).
93 pub fn from_epoch_days(days: i64) -> Self {
94 // Howard Hinnant's civil_from_days.
95 let z = days + 719468;
96 let era = if z >= 0 { z } else { z - 146096 } / 146097;
97 let doe = z - era * 146097;
98 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
99 let y = yoe + era * 400;
100 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
101 let mp = (5 * doy + 2) / 153;
102 let day = (doy - (153 * mp + 2) / 5 + 1) as u8;
103 let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u8;
104 DayDate {
105 year: (y + if month <= 2 { 1 } else { 0 }) as i32,
106 month,
107 day,
108 }
109 }
110
111 /// Parse strict ISO-8601 `YYYY-MM-DD` (the form `Display` writes). `None` on anything else.
112 pub fn parse_iso(s: &str) -> Option<Self> {
113 let mut it = s.split('-');
114 let (y, m, d) = (it.next()?, it.next()?, it.next()?);
115 if it.next().is_some() || y.len() != 4 || m.len() != 2 || d.len() != 2 {
116 return None;
117 }
118 if [y, m, d]
119 .iter()
120 .any(|p| !p.bytes().all(|b| b.is_ascii_digit()))
121 {
122 return None;
123 }
124 Self::new(y.parse().ok()?, m.parse().ok()?, d.parse().ok()?)
125 }
126
127 /// This date limited to the inclusive `[min, max]` range (either bound optional).
128 pub fn clamped(self, min: Option<DayDate>, max: Option<DayDate>) -> Self {
129 let mut d = self;
130 if let Some(min) = min
131 && d < min
132 {
133 d = min;
134 }
135 if let Some(max) = max
136 && d > max
137 {
138 d = max;
139 }
140 d
141 }
142}
143
144impl std::fmt::Display for DayDate {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
147 }
148}
149
150pub fn is_leap(year: i32) -> bool {
151 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
152}
153
154pub fn days_in_month(year: i32, month: u8) -> u8 {
155 match month {
156 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
157 4 | 6 | 9 | 11 => 30,
158 2 if is_leap(year) => 29,
159 2 => 28,
160 _ => 0,
161 }
162}
163
164/// A civil (zoneless) wall-clock time. `Display` and [`DayTime::parse_iso`] speak ISO-8601
165/// (`HH:MM`, or `HH:MM:SS` when seconds are nonzero).
166#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
167pub struct DayTime {
168 pub hour: u8,
169 pub minute: u8,
170 pub second: u8,
171}
172
173impl DayTime {
174 /// A validated time (`hour` 0–23, `minute`/`second` 0–59); `None` otherwise.
175 pub fn new(hour: u8, minute: u8, second: u8) -> Option<Self> {
176 if hour < 24 && minute < 60 && second < 60 {
177 Some(DayTime {
178 hour,
179 minute,
180 second,
181 })
182 } else {
183 None
184 }
185 }
186
187 /// The current time of day from the system clock in UTC, the twin of [`DayDate::today`],
188 /// and carrying the same caveat: Day ships no time-zone database, so an app that needs local
189 /// wall-clock time should apply its own offset. Pair the two for a timestamp
190 /// (`Day-Showcase-2026-08-12-14-23-05.png`).
191 pub fn now() -> Self {
192 Self::from_seconds_of_day(now_epoch_secs().rem_euclid(86_400))
193 }
194
195 /// Seconds since midnight, the numeric interchange form across native boundaries.
196 pub fn seconds_of_day(self) -> i64 {
197 self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64
198 }
199
200 /// The time `secs` after midnight (inverse of [`DayTime::seconds_of_day`]; out-of-range wraps
201 /// into the day).
202 pub fn from_seconds_of_day(secs: i64) -> Self {
203 let s = secs.rem_euclid(86_400);
204 DayTime {
205 hour: (s / 3600) as u8,
206 minute: (s / 60 % 60) as u8,
207 second: (s % 60) as u8,
208 }
209 }
210
211 /// Parse ISO-8601 `HH:MM` or `HH:MM:SS`. `None` on anything else.
212 pub fn parse_iso(s: &str) -> Option<Self> {
213 let mut it = s.split(':');
214 let (h, m) = (it.next()?, it.next()?);
215 let sec = it.next();
216 if it.next().is_some() || h.len() != 2 || m.len() != 2 {
217 return None;
218 }
219 if let Some(sec) = sec
220 && sec.len() != 2
221 {
222 return None;
223 }
224 if [Some(h), Some(m), sec]
225 .iter()
226 .flatten()
227 .any(|p| !p.bytes().all(|b| b.is_ascii_digit()))
228 {
229 return None;
230 }
231 Self::new(
232 h.parse().ok()?,
233 m.parse().ok()?,
234 sec.map_or(Some(0), |s| s.parse().ok())?,
235 )
236 }
237}
238
239impl std::fmt::Display for DayTime {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 if self.second == 0 {
242 write!(f, "{:02}:{:02}", self.hour, self.minute)
243 } else {
244 write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)
245 }
246 }
247}
248
249// ---------------------------------------------------------------------------
250// The pieces
251// ---------------------------------------------------------------------------
252
253/// How the picker presents (docs/datepicker.md has the per-toolkit table). Like `PickerStyle`,
254/// each intent maps to the platform's closest idiomatic control, so chrome differs per platform.
255#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
256pub enum Style {
257 /// The platform default: [`Style::Compact`] everywhere today.
258 #[default]
259 Automatic,
260 /// A field/button showing the value that summons a transient chooser (NSDatePicker
261 /// textFieldAndStepper / UIDatePicker .compact / a Material dialog launcher / QDateEdit with
262 /// calendar popup / CalendarDatePicker flyout / GtkMenuButton+calendar popover /
263 /// ArkUI CalendarPicker).
264 Compact,
265 /// An embedded calendar / clock / wheels (NSDatePicker graphical / UIDatePicker .inline /
266 /// framework DatePicker+TimePicker widgets / QCalendarWidget / CalendarView / GtkCalendar /
267 /// ArkUI DatePicker wheels).
268 Inline,
269}
270
271/// Full props (realize) for the date picker. `style`/`min`/`max` are set once at build; only the
272/// value patches.
273#[derive(Clone, Copy, Debug, Default, PartialEq)]
274pub struct DateProps {
275 pub date: DayDate,
276 pub style: Style,
277 pub min: Option<DayDate>,
278 pub max: Option<DayDate>,
279}
280
281#[derive(Clone, Copy, Debug, PartialEq)]
282pub enum DatePatch {
283 SetDate(DayDate),
284}
285
286/// Full props (realize) for the time picker. `style`/`seconds` are set once at build; only the
287/// value patches.
288#[derive(Clone, Copy, Debug, Default, PartialEq)]
289pub struct TimeProps {
290 pub time: DayTime,
291 pub style: Style,
292 /// Show/edit seconds; honored on AppKit and Qt (the toolkits whose controls have a seconds
293 /// field), a documented no-op elsewhere.
294 pub seconds: bool,
295}
296
297#[derive(Clone, Copy, Debug, PartialEq)]
298pub enum TimePatch {
299 SetTime(DayTime),
300}