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}