ailloli_ui_core/time_picker.rs
1//! Pure time picker values and formatting helpers.
2
3/// A wall-clock time with minute precision.
4///
5/// # Examples
6///
7/// ```
8/// use ailloli_ui_core::TimeValue;
9///
10/// let time = TimeValue::new(9, 30);
11/// assert_eq!((time.hour, time.minute), (9, 30));
12/// ```
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct TimeValue {
15 /// Hour in `0..=23` for values created by [`TimeValue::new`].
16 pub hour: u8,
17 /// Minute in `0..=59` for values created by [`TimeValue::new`].
18 pub minute: u8,
19}
20
21/// Text representation used to parse or format [`TimeValue`].
22///
23/// Possible values are [`TimeFormat::Hour24`] and [`TimeFormat::Hour12`].
24///
25/// # Examples
26///
27/// ```
28/// use ailloli_ui_core::{TimeFormat, TimeValue};
29///
30/// assert_eq!(TimeValue::new(13, 5).format(TimeFormat::Hour12), "1:05 PM");
31/// ```
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33pub enum TimeFormat {
34 /// Zero-padded 24-hour `HH:MM`; this is the default.
35 #[default]
36 Hour24,
37 /// Twelve-hour `H:MM AM` or `H:MM PM`.
38 Hour12,
39}
40
41impl TimeValue {
42 /// Creates a time, clamping the hour to 23 and the minute to 59.
43 ///
44 /// # Examples
45 ///
46 /// ```
47 /// use ailloli_ui_core::TimeValue;
48 ///
49 /// assert_eq!(TimeValue::new(25, 90), TimeValue { hour: 23, minute: 59 });
50 /// ```
51 pub fn new(hour: u8, minute: u8) -> Self {
52 Self {
53 hour: hour.min(23),
54 minute: minute.min(59),
55 }
56 }
57
58 /// Returns minutes elapsed since midnight in `0..=1439`.
59 ///
60 /// # Examples
61 ///
62 /// ```
63 /// use ailloli_ui_core::TimeValue;
64 ///
65 /// assert_eq!(TimeValue::new(1, 30).total_minutes(), 90);
66 /// ```
67 pub fn total_minutes(self) -> u16 {
68 self.hour as u16 * 60 + self.minute as u16
69 }
70
71 /// Creates a time from minutes since midnight, clamped to 23:59.
72 ///
73 /// # Examples
74 ///
75 /// ```
76 /// use ailloli_ui_core::TimeValue;
77 ///
78 /// assert_eq!(TimeValue::from_total_minutes(75), TimeValue::new(1, 15));
79 /// ```
80 pub fn from_total_minutes(minutes: u16) -> Self {
81 let minutes = minutes.min(23 * 60 + 59);
82 Self::new((minutes / 60) as u8, (minutes % 60) as u8)
83 }
84
85 /// Formats this value using the selected 12- or 24-hour representation.
86 ///
87 /// # Examples
88 ///
89 /// ```
90 /// use ailloli_ui_core::{TimeFormat, TimeValue};
91 ///
92 /// assert_eq!(TimeValue::new(0, 5).format(TimeFormat::Hour24), "00:05");
93 /// ```
94 pub fn format(self, format: TimeFormat) -> String {
95 match format {
96 TimeFormat::Hour24 => format!("{:02}:{:02}", self.hour, self.minute),
97 TimeFormat::Hour12 => {
98 let suffix = if self.hour < 12 { "AM" } else { "PM" };
99 let hour = match self.hour % 12 {
100 0 => 12,
101 h => h,
102 };
103 format!("{hour}:{:02} {suffix}", self.minute)
104 }
105 }
106 }
107}
108
109/// Parses a time in the exact representation selected by `format`.
110///
111/// The 24-hour form requires `hour:minute` with values in `0..=23` and
112/// `0..=59`. The 12-hour form requires a space plus case-insensitive `AM` or
113/// `PM`, and an hour in `1..=12`. Numeric fields need not be zero-padded.
114/// Returns `None` for malformed or out-of-range input.
115///
116/// # Examples
117///
118/// ```
119/// use ailloli_ui_core::{TimeFormat, TimeValue};
120/// use ailloli_ui_core::time_picker::parse_time;
121///
122/// assert_eq!(parse_time("2:05 PM", TimeFormat::Hour12), Some(TimeValue::new(14, 5)));
123/// assert_eq!(parse_time("25:00", TimeFormat::Hour24), None);
124/// ```
125pub fn parse_time(input: &str, format: TimeFormat) -> Option<TimeValue> {
126 match format {
127 TimeFormat::Hour24 => {
128 let mut parts = input.split(':');
129 let hour = parts.next()?.parse::<u8>().ok()?;
130 let minute = parts.next()?.parse::<u8>().ok()?;
131 if parts.next().is_some() || hour > 23 || minute > 59 {
132 return None;
133 }
134 Some(TimeValue { hour, minute })
135 }
136 TimeFormat::Hour12 => {
137 let trimmed = input.trim();
138 let (time, suffix) = trimmed.rsplit_once(' ')?;
139 let suffix = suffix.to_ascii_uppercase();
140 if suffix != "AM" && suffix != "PM" {
141 return None;
142 }
143 let mut parts = time.split(':');
144 let hour12 = parts.next()?.parse::<u8>().ok()?;
145 let minute = parts.next()?.parse::<u8>().ok()?;
146 if parts.next().is_some() || !(1..=12).contains(&hour12) || minute > 59 {
147 return None;
148 }
149 let hour = match (hour12, suffix.as_str()) {
150 (12, "AM") => 0,
151 (12, "PM") => 12,
152 (h, "PM") => h + 12,
153 (h, _) => h,
154 };
155 Some(TimeValue { hour, minute })
156 }
157 }
158}
159
160/// Clamps a minute step to the supported inclusive range `1..=60`.
161///
162/// # Examples
163///
164/// ```
165/// use ailloli_ui_core::time_picker::sanitize_step_minutes;
166///
167/// assert_eq!(sanitize_step_minutes(0), 1);
168/// assert_eq!(sanitize_step_minutes(90), 60);
169/// ```
170pub fn sanitize_step_minutes(step: u8) -> u8 {
171 step.clamp(1, 60)
172}
173
174/// Rounds a time to the nearest sanitized minute step.
175///
176/// Half-step ties round upward. The final value is clamped to 23:59 instead of
177/// wrapping into the next day.
178///
179/// # Examples
180///
181/// ```
182/// use ailloli_ui_core::TimeValue;
183/// use ailloli_ui_core::time_picker::snap_time;
184///
185/// assert_eq!(snap_time(TimeValue::new(10, 8), 5), TimeValue::new(10, 10));
186/// ```
187pub fn snap_time(value: TimeValue, step_minutes: u8) -> TimeValue {
188 let step = sanitize_step_minutes(step_minutes) as u16;
189 let minutes = value.total_minutes();
190 let snapped = ((minutes + step / 2) / step) * step;
191 TimeValue::from_total_minutes(snapped.min(23 * 60 + 59))
192}
193
194/// Moves a time by `delta_minutes`, clamps it to the current day, then snaps it.
195///
196/// No wrap occurs at midnight. `step_minutes` is clamped with
197/// [`sanitize_step_minutes`].
198///
199/// # Panics
200///
201/// Debug builds may panic if adding an extreme `i16` delta to the current
202/// minute count overflows `i16`.
203///
204/// # Examples
205///
206/// ```
207/// use ailloli_ui_core::TimeValue;
208/// use ailloli_ui_core::time_picker::nudge_time;
209///
210/// assert_eq!(nudge_time(TimeValue::new(10, 0), 5, 5), TimeValue::new(10, 5));
211/// ```
212pub fn nudge_time(value: TimeValue, delta_minutes: i16, step_minutes: u8) -> TimeValue {
213 let current = value.total_minutes() as i16;
214 let next = (current + delta_minutes).clamp(0, 23 * 60 + 59);
215 snap_time(TimeValue::from_total_minutes(next as u16), step_minutes)
216}
217
218#[cfg(test)]
219mod tests {
220 //! Covers both text formats and nearest-step snapping.
221
222 use super::*;
223
224 #[test]
225 fn time_formats_and_parses() {
226 let value = TimeValue::new(14, 5);
227 assert_eq!(value.format(TimeFormat::Hour24), "14:05");
228 assert_eq!(value.format(TimeFormat::Hour12), "2:05 PM");
229 assert_eq!(parse_time("2:05 PM", TimeFormat::Hour12), Some(value));
230 assert_eq!(parse_time("14:05", TimeFormat::Hour24), Some(value));
231 }
232
233 #[test]
234 fn snap_uses_nearest_step() {
235 assert_eq!(snap_time(TimeValue::new(10, 7), 5), TimeValue::new(10, 5));
236 assert_eq!(snap_time(TimeValue::new(10, 8), 5), TimeValue::new(10, 10));
237 }
238}