Skip to main content

qframe/widgets/duration_input/
parse.rs

1//! Reading a length of time written by hand, such as `1 h 30 min`, `90 dk` or `2:15`, and writing
2//! one back in the active language.
3
4use std::time::Duration;
5
6use crate::i18n::{Arg, I18n};
7
8/// The longest length a duration holds: 99 hours, 59 minutes and 59 seconds, in seconds. Two
9/// digits of hours cover any timer, target or timeout, and keep the field one width.
10pub(crate) const LONGEST: u64 = 99 * 3600 + 59 * 60 + 59;
11
12/// A unit of a length of time.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DurationUnit {
15    /// Hours: `h`, `hour`, `sa`, `saat` and the other words of the language files.
16    Hours,
17    /// Minutes: `min`, `m`, `dk`, `dakika` and the other words of the language files.
18    Minutes,
19    /// Seconds: `s`, `sec`, `sn`, `saniye` and the other words of the language files.
20    Seconds,
21}
22
23impl DurationUnit {
24    /// Every unit, largest first; the index of a unit is its segment in the field.
25    pub(crate) const ALL: [Self; 3] = [Self::Hours, Self::Minutes, Self::Seconds];
26
27    /// Seconds in one of this unit.
28    pub(crate) fn seconds(self) -> u64 {
29        match self {
30            Self::Hours => 3600,
31            Self::Minutes => 60,
32            Self::Seconds => 1,
33        }
34    }
35
36    /// The stem of this unit's language keys.
37    fn stem(self) -> &'static str {
38        match self {
39            Self::Hours => "hour",
40            Self::Minutes => "minute",
41            Self::Seconds => "second",
42        }
43    }
44
45    /// The next smaller unit, which a bare number after this one is read in.
46    fn below(self) -> Option<Self> {
47        match self {
48            Self::Hours => Some(Self::Minutes),
49            Self::Minutes => Some(Self::Seconds),
50            Self::Seconds => None,
51        }
52    }
53
54    /// The short word shown after a number in the active language: `h`, `min`, `s`.
55    pub(crate) fn short(self, i18n: &I18n) -> String {
56        i18n.translate(&format!("quvyta.duration.{}s", self.stem()), &[])
57    }
58}
59
60/// Why a written length of time could not be read. [`message`](Self::message) says it in the
61/// active language.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum DurationError {
64    /// Nothing but spaces.
65    Empty,
66    /// A character that has no place in a length of time, such as `-` or `/`.
67    Character(char),
68    /// A word that is no unit of time in any known language, such as `days`.
69    UnknownUnit(String),
70    /// A number that cannot be read, such as `1.2.3`.
71    BadNumber(String),
72    /// A unit with no number before it, such as the `h` of `h 30`.
73    MissingNumber(String),
74    /// A number with no unit that cannot take one from its place, such as the `30` of `1 30 min`.
75    MissingUnit(String),
76    /// A unit written twice, such as `1 h 2 h`.
77    RepeatedUnit(DurationUnit),
78    /// Text with a colon that is not `h:mm` or `h:mm:ss` with minutes and seconds under 60.
79    BadClock(String),
80    /// Longer than 99 hours, 59 minutes and 59 seconds.
81    TooLarge,
82}
83
84impl DurationError {
85    /// What is wrong, in the active language of `i18n`, from the `quvyta.duration.*` keys.
86    #[must_use]
87    pub fn message(&self, i18n: &I18n) -> String {
88        let (key, text) = match self {
89            Self::Empty => ("empty", String::new()),
90            Self::Character(c) => ("character", c.to_string()),
91            Self::UnknownUnit(word) => ("unit", word.clone()),
92            Self::BadNumber(number) => ("number", number.clone()),
93            Self::MissingNumber(word) => ("no-number", word.clone()),
94            Self::MissingUnit(number) => ("no-unit", number.clone()),
95            Self::RepeatedUnit(unit) => {
96                ("repeated", i18n.translate(&format!("quvyta.duration.{}-name", unit.stem()), &[]))
97            }
98            Self::BadClock(text) => ("clock", text.clone()),
99            Self::TooLarge => ("too-large", write(Duration::from_secs(LONGEST), true, i18n)),
100        };
101        i18n.translate(&format!("quvyta.duration.{key}"), &[("text", Arg::Text(text))])
102    }
103}
104
105/// Reads a length of time written by hand, in any language `i18n` knows.
106///
107/// Three forms are read:
108///
109/// - **Numbers with units**, in any order, each unit once: `1 h 30 min`, `1sa30dk`, `90 dk`,
110///   `2 hours`, `1,5 sa`. The unit words come from `quvyta.duration.hour-words`,
111///   `minute-words` and `second-words` of every language, the active one first; case does not
112///   matter, and Turkish dotted and dotless i read alike. A number after a unit with no unit of
113///   its own takes the next smaller one: `1 h 30` is an hour and a half. A decimal point or comma
114///   splits a unit (`1.5 h` is 90 minutes), rounded to the second. Minutes and seconds may run
115///   past 60 and carry over: `1 h 90 min` is two hours and a half.
116/// - **A clock face**, `h:mm` or `h:mm:ss`: `2:15` is two hours and a quarter, never two minutes.
117///   Minutes and seconds are one or two digits under 60.
118/// - **A bare number**, read in minutes: `25` is 25 minutes, the length of most timers.
119///
120/// # Errors
121///
122/// A [`DurationError`] naming the first part that could not be read, or
123/// [`DurationError::TooLarge`] past 99 hours, 59 minutes and 59 seconds.
124pub fn parse_duration(text: &str, i18n: &I18n) -> Result<Duration, DurationError> {
125    let text = text.trim();
126    if text.is_empty() {
127        return Err(DurationError::Empty);
128    }
129    let seconds = if text.contains(':') { clock(text)? } else { with_units(&tokens(text)?, i18n)? };
130    if seconds > u128::from(LONGEST) {
131        return Err(DurationError::TooLarge);
132    }
133    Ok(Duration::from_secs(u64::try_from(seconds).unwrap_or(LONGEST)))
134}
135
136/// Writes `duration` as the field copies it, in the active language: `1 h 30 min`, `45 min`,
137/// `2 h`, and `0 min` for nothing. Parts that are zero are left out; seconds only with `seconds`.
138pub(crate) fn write(duration: Duration, seconds: bool, i18n: &I18n) -> String {
139    let total = duration.as_secs();
140    let parts = [total / 3600, total / 60 % 60, total % 60];
141    let shown = if seconds { 3 } else { 2 };
142    let written: Vec<String> = DurationUnit::ALL[..shown]
143        .iter()
144        .zip(parts)
145        .filter(|(_, value)| *value > 0)
146        .map(|(unit, value)| format!("{value} {}", unit.short(i18n)))
147        .collect();
148    if written.is_empty() { format!("0 {}", DurationUnit::Minutes.short(i18n)) } else { written.join(" ") }
149}
150
151/// Digits past which a number is longer than any length can be.
152const MAX_DIGITS: usize = 12;
153
154/// A number as written: its text, the whole part and the digits after the decimal mark.
155#[derive(Debug)]
156struct Number {
157    text: String,
158    whole: u128,
159    fraction: String,
160}
161
162impl Number {
163    /// Seconds in this many `unit`s, the fraction rounded to the nearest second.
164    fn seconds(&self, unit: DurationUnit) -> u128 {
165        let unit = u128::from(unit.seconds());
166        // Nine digits resolve any fraction of an hour far below a second.
167        let digits = &self.fraction[..self.fraction.len().min(9)];
168        let scale = 10u128.pow(u32::try_from(digits.len()).unwrap_or(0));
169        let fraction: u128 = digits.parse().unwrap_or(0);
170        self.whole * unit + (fraction * unit + scale / 2) / scale
171    }
172}
173
174#[derive(Debug)]
175enum Token {
176    Number(Number),
177    Word(String),
178}
179
180/// Splits `text` into numbers and words. Spaces, and commas or points that are not inside a
181/// number, only separate: `1 h, 30 min` reads like `1 h 30 min`.
182fn tokens(text: &str) -> Result<Vec<Token>, DurationError> {
183    let chars: Vec<char> = text.chars().collect();
184    let is_mark = |c: char| c == '.' || c == ',';
185    let digit_at = |i: usize| chars.get(i).is_some_and(char::is_ascii_digit);
186    let mut out = Vec::new();
187    let mut i = 0;
188    while let Some(&c) = chars.get(i) {
189        let start = i;
190        if c.is_ascii_digit() || (is_mark(c) && digit_at(i + 1)) {
191            let run = |i: &mut usize| {
192                while digit_at(*i) {
193                    *i += 1;
194                }
195            };
196            run(&mut i);
197            let whole_end = i;
198            let mut marks = 0;
199            while chars.get(i).is_some_and(|c| is_mark(*c)) && digit_at(i + 1) {
200                marks += 1;
201                i += 1;
202                run(&mut i);
203            }
204            let written: String = chars[start..i].iter().collect();
205            let whole: String = chars[start..whole_end].iter().collect();
206            if marks > 1 || whole.is_empty() {
207                return Err(DurationError::BadNumber(written));
208            }
209            if whole.trim_start_matches('0').len() > MAX_DIGITS {
210                return Err(DurationError::TooLarge);
211            }
212            let fraction = if marks == 1 { chars[whole_end + 1..i].iter().collect() } else { String::new() };
213            out.push(Token::Number(Number { text: written, whole: whole.parse().unwrap_or(0), fraction }));
214        } else if c.is_alphabetic() {
215            while chars.get(i).is_some_and(|c| c.is_alphabetic()) {
216                i += 1;
217            }
218            out.push(Token::Word(chars[start..i].iter().collect()));
219        } else if c.is_whitespace() || is_mark(c) {
220            i += 1;
221        } else {
222            return Err(DurationError::Character(c));
223        }
224    }
225    Ok(out)
226}
227
228/// Lower case for matching unit words, with the Turkish `İ`, `I` and `ı` all read as `i`, so
229/// `DAKİKA`, `DAKIKA` and `MIN` match whatever the keyboard's language.
230fn fold(word: &str) -> String {
231    word.chars()
232        .flat_map(|c| match c {
233            'İ' | 'I' | 'ı' => vec!['i'],
234            c => c.to_lowercase().collect(),
235        })
236        .collect()
237}
238
239/// The unit `word` names: in the active language first, then in any language.
240fn unit_of(word: &str, i18n: &I18n) -> Option<DurationUnit> {
241    let word = fold(word);
242    let names = |unit: DurationUnit| format!("quvyta.duration.{}-words", unit.stem());
243    let matches = |list: &str| list.split(',').any(|candidate| fold(candidate.trim()) == word);
244    let active = DurationUnit::ALL.into_iter().find(|unit| matches(&i18n.translate(&names(*unit), &[])));
245    active.or_else(|| {
246        DurationUnit::ALL.into_iter().find(|unit| i18n.in_every_locale(&names(*unit)).iter().any(|list| matches(list)))
247    })
248}
249
250/// Seconds in numbers with units.
251fn with_units(tokens: &[Token], i18n: &I18n) -> Result<u128, DurationError> {
252    if tokens.is_empty() {
253        return Err(DurationError::Empty);
254    }
255    let mut total = 0u128;
256    let mut seen = Vec::new();
257    let mut last = None;
258    let mut waiting: Option<&Number> = None;
259    let mut add = |number: &Number, unit: DurationUnit, seen: &mut Vec<DurationUnit>| {
260        if seen.contains(&unit) {
261            return Err(DurationError::RepeatedUnit(unit));
262        }
263        seen.push(unit);
264        total += number.seconds(unit);
265        Ok(())
266    };
267    for token in tokens {
268        match token {
269            Token::Number(number) => {
270                if let Some(previous) = waiting {
271                    return Err(DurationError::MissingUnit(previous.text.clone()));
272                }
273                waiting = Some(number);
274            }
275            Token::Word(word) => {
276                let unit = unit_of(word, i18n).ok_or_else(|| DurationError::UnknownUnit(word.clone()))?;
277                let number = waiting.take().ok_or_else(|| DurationError::MissingNumber(word.clone()))?;
278                add(number, unit, &mut seen)?;
279                last = Some(unit);
280            }
281        }
282    }
283    if let Some(number) = waiting {
284        let unit = match last {
285            None => DurationUnit::Minutes,
286            Some(unit) => unit.below().ok_or_else(|| DurationError::MissingUnit(number.text.clone()))?,
287        };
288        add(number, unit, &mut seen)?;
289    }
290    Ok(total)
291}
292
293/// Seconds in `h:mm` or `h:mm:ss`.
294fn clock(text: &str) -> Result<u128, DurationError> {
295    let bad = || DurationError::BadClock(text.to_owned());
296    let parts: Vec<&str> = text.split(':').map(str::trim).collect();
297    if !(2..=3).contains(&parts.len())
298        || parts.iter().any(|part| part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()))
299    {
300        return Err(bad());
301    }
302    if parts[0].trim_start_matches('0').len() > MAX_DIGITS {
303        return Err(DurationError::TooLarge);
304    }
305    let mut total: u128 = parts[0].parse::<u128>().map_err(|_| bad())? * 3600;
306    for (part, unit) in parts[1..].iter().zip([60u128, 1]) {
307        let value: u128 = part.parse().map_err(|_| bad())?;
308        if part.len() > 2 || value >= 60 {
309            return Err(bad());
310        }
311        total += value * unit;
312    }
313    Ok(total)
314}