Skip to main content

qframe/widgets/duration_input/
mod.rs

1//! Length of time entry: hours and minutes, optionally seconds.
2
3mod parse;
4#[cfg(test)]
5mod tests;
6
7use std::time::Duration;
8
9pub use parse::{DurationError, DurationUnit, parse_duration};
10
11use super::edit_menu::{self, EditAction, TextMenu};
12use crate::event::{Event, KeyEvent, MouseButton, MouseKind};
13use crate::geometry::{Rect, Size};
14use crate::i18n::I18n;
15use crate::keymap::{Key, Modifiers, Scope};
16use crate::style::CellStyle;
17use crate::text;
18use crate::theme::State;
19use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
20use parse::LONGEST;
21
22/// Builds a message from a new length of time.
23type DurationMessage<Msg> = Box<dyn Fn(Duration) -> Msg>;
24
25/// Builds a message from a paste that could not be read.
26type RejectMessage<Msg> = Box<dyn Fn(DurationError) -> Msg>;
27
28/// Hours, minutes and seconds of `total` seconds; hours are not capped.
29fn parts(total: u64) -> [u64; 3] {
30    [total / 3600, total / 60 % 60, total % 60]
31}
32
33/// `total` with the part at `index` set to `value`. Minutes and seconds past 59 carry into the
34/// larger units, so typing 90 into the minutes gives an hour and a half; the result stops at the
35/// longest length.
36fn with_part(total: u64, index: usize, value: u64) -> u64 {
37    let mut parts = parts(total);
38    parts[index] = value;
39    (parts[0] * 3600 + parts[1] * 60 + parts[2]).min(LONGEST)
40}
41
42/// Digits in `value`, at least one.
43fn digits(value: u64) -> u16 {
44    u16::try_from(value.checked_ilog10().unwrap_or(0) + 1).unwrap_or(u16::MAX)
45}
46
47/// Where the segments and the marks between them sit in a field, relative to its left edge.
48#[derive(Debug, PartialEq, Eq)]
49struct Layout {
50    /// Left edge and width of every segment: a cell of room, the digits, a cell of room.
51    segments: Vec<(u16, u16)>,
52    /// The unit word after every segment, or a colon between segments in the compact form.
53    marks: Vec<(u16, String)>,
54    width: u16,
55}
56
57/// A length of time edited segment by segment: hours and minutes, optionally seconds, each
58/// followed by its unit in the active language: `1 h 30 min`, `1 sa 30 dk`.
59///
60/// It is built like [`TimeInput`](super::TimeInput) and handled the same way. The field takes
61/// focus as one control and one segment at a time is active: Left and Right move between
62/// segments, Up and Down change the active segment by one of its unit, and typing digits fills
63/// it, two digits a segment, moving on once the segment is complete. `:` and Space move on as
64/// well and Backspace sets the segment to zero. A click activates the segment under the pointer.
65/// The wheel changes the segment under the pointer, one unit per notch, without moving focus or
66/// the active segment; over a unit word it changes the segment the pointer was last over inside
67/// the field, or the active segment when there was none. The application owns the length.
68///
69/// Unlike a time of day a length does not wrap: stepping carries between the units (0 h 59 min
70/// up is 1 h 00 min), stops at zero going down and at 99 h 59 min 59 s going up. Minutes and
71/// seconds typed past 59 carry too, so `90` typed into the minutes reads `1 h 30 min`.
72///
73/// Ctrl+A selects the whole length; then Ctrl+C copies it as written in the active language
74/// (`1 h 30 min`) and Ctrl+X copies it and sets the length to zero. Pasting sets the length from
75/// anything [`parse_duration`] reads (`90 dk`, `1 h 30 min`, `2:15`); other text is ignored, or
76/// sent to [`on_reject`](Self::on_reject) with the reason. A right click (or Shift+F10 and the
77/// menu key) opens the edit menu of [`TextInput`](super::TextInput) with the same actions; Cut
78/// and Copy need the whole length selected.
79///
80/// A zero length is the field's empty state: its digits are drawn as faint as the unit words
81/// until the pointer or focus is on the field. Where the words do not fit, the field shows the
82/// compact clock form, `1 : 30`, with the same segments and keys.
83///
84/// Style keys, shared with [`TimeInput`](super::TimeInput) so both fields look alike:
85/// `time-input` (`bg`, `fg`) with states `hover`, `focus`, `invalid`, `disabled`; `time-segment`
86/// (`bg`, `fg`, `bold`) with `selected` for the active segment and `active` while a first digit
87/// waits for the second; `time-separator` (`fg`) for the unit words, the colons and the digits of
88/// a zero length at rest; `text-input-selection` (`bg`, `fg`) for the whole length selected.
89/// Language keys: `quvyta.duration.*` for the units and the reasons a paste was not read,
90/// `quvyta.edit.*` for the menu.
91pub struct DurationInput<Msg> {
92    value: Duration,
93    seconds: bool,
94    invalid: bool,
95    disabled: bool,
96    on_change: Option<DurationMessage<Msg>>,
97    on_reject: Option<RejectMessage<Msg>>,
98}
99
100#[derive(Debug, Default)]
101struct DurationMemory {
102    /// The active segment.
103    segment: usize,
104    /// A first digit typed into the active segment, waiting for the second.
105    pending: Option<u8>,
106    /// Whether the whole length is selected, for copying.
107    all: bool,
108    /// The segment the pointer was last over since it entered the field; the wheel over a unit
109    /// word changes it. Forgotten when the pointer leaves the field.
110    hovered: Option<usize>,
111}
112
113impl<Msg> DurationInput<Msg> {
114    /// A field showing `value` as hours and minutes. Parts of a second are not shown and are
115    /// dropped by the first change.
116    #[must_use]
117    pub fn new(value: Duration) -> Self {
118        Self { value, seconds: false, invalid: false, disabled: false, on_change: None, on_reject: None }
119    }
120
121    /// Shows and edits seconds too.
122    #[must_use]
123    pub fn seconds(mut self, seconds: bool) -> Self {
124        self.seconds = seconds;
125        self
126    }
127
128    /// Marks the length as failing validation, such as a break longer than the session.
129    #[must_use]
130    pub fn invalid(mut self, invalid: bool) -> Self {
131        self.invalid = invalid;
132        self
133    }
134
135    /// Greys the field out; it cannot be focused or changed.
136    #[must_use]
137    pub fn disabled(mut self, disabled: bool) -> Self {
138        self.disabled = disabled;
139        self
140    }
141
142    /// Message carrying the new length after every change.
143    #[must_use]
144    pub fn on_change(mut self, message: impl Fn(Duration) -> Msg + 'static) -> Self {
145        self.on_change = Some(Box::new(message));
146        self
147    }
148
149    /// Message carrying why a pasted text could not be read, so the application can say so
150    /// next to the field with [`DurationError::message`]. Without it such a paste is ignored and
151    /// passed on, as in [`TimeInput`](super::TimeInput).
152    #[must_use]
153    pub fn on_reject(mut self, message: impl Fn(DurationError) -> Msg + 'static) -> Self {
154        self.on_reject = Some(Box::new(message));
155        self
156    }
157
158    fn active(&self) -> bool {
159        !self.disabled && self.on_change.is_some()
160    }
161
162    fn count(&self) -> usize {
163        if self.seconds { 3 } else { 2 }
164    }
165
166    fn total(&self) -> u64 {
167        self.value.as_secs()
168    }
169
170    /// The layout with unit words from `i18n`, or the compact clock form without.
171    fn layout(&self, words: Option<&I18n>) -> Layout {
172        let parts = parts(self.total());
173        let mut segments = Vec::new();
174        let mut marks = Vec::new();
175        let mut x: u16 = 0;
176        for (index, unit) in DurationUnit::ALL[..self.count()].iter().enumerate() {
177            if words.is_none() && index > 0 {
178                marks.push((x, ":".to_owned()));
179                x = x.saturating_add(1);
180            }
181            // Hours take two digits, more only for a length handed in beyond the longest.
182            let width = if index == 0 { digits(parts[0]).max(2) } else { 2 };
183            segments.push((x, width + 2));
184            x = x.saturating_add(width + 2);
185            if let Some(i18n) = words {
186                let word = unit.short(i18n);
187                let width = text::width(&word);
188                marks.push((x, word));
189                x = x.saturating_add(width);
190            }
191        }
192        Layout { segments, marks, width: x }
193    }
194
195    /// The layout the field has in `width` cells: with unit words when they fit.
196    fn fitted(&self, i18n: &I18n, width: u16) -> Layout {
197        let full = self.layout(Some(i18n));
198        if full.width <= width { full } else { self.layout(None) }
199    }
200
201    fn change(&self, cx: &mut EventCx<'_, Msg>, total: u64) {
202        if total != self.total()
203            && let Some(message) = &self.on_change
204        {
205            cx.emit(message(Duration::from_secs(total)));
206        }
207    }
208
209    /// Types `digit` into the active segment: two digits make a segment.
210    fn type_digit(&self, cx: &mut EventCx<'_, Msg>, digit: u8) {
211        let last = self.count() - 1;
212        let memory = cx.memory::<DurationMemory>();
213        let segment = memory.segment.min(last);
214        let (value, complete) = match memory.pending {
215            Some(first) => (first * 10 + digit, true),
216            None => (digit, false),
217        };
218        memory.pending = if complete { None } else { Some(digit) };
219        if complete && segment < last {
220            memory.segment = segment + 1;
221        }
222        self.change(cx, with_part(self.total(), segment, u64::from(value)));
223    }
224
225    /// Moves the length one unit of segment `segment` up or down, carrying between units and
226    /// stopping at zero and at the longest length.
227    fn step(&self, cx: &mut EventCx<'_, Msg>, segment: usize, up: bool) {
228        let last = self.count() - 1;
229        let memory = cx.memory::<DurationMemory>();
230        // A first digit waiting in the active segment stays when the wheel changes another one.
231        if segment == memory.segment.min(last) {
232            memory.pending = None;
233        }
234        let unit = DurationUnit::ALL[segment].seconds();
235        let total = self.total();
236        let next = if up { (total + unit).min(LONGEST.max(total)) } else { total.saturating_sub(unit) };
237        self.change(cx, next);
238    }
239
240    /// The segment whose cells include column `offset` of the field; `None` over a unit word, a
241    /// colon or past the end.
242    fn segment_at(layout: &Layout, offset: i32) -> Option<usize> {
243        layout.segments.iter().position(|(start, width)| {
244            let start = i32::from(*start);
245            (start..start + i32::from(*width)).contains(&offset)
246        })
247    }
248
249    /// The layout of the field handling an event.
250    fn event_layout(&self, cx: &EventCx<'_, Msg>) -> Layout {
251        self.fitted(cx.env().i18n(), cx.area().width)
252    }
253
254    /// The segment the wheel changes at column `x`: the one under the pointer, else the one it
255    /// was last over inside the field, else the active one.
256    fn wheel_target(&self, cx: &mut EventCx<'_, Msg>, x: i32) -> usize {
257        let last = self.count() - 1;
258        let under = Self::segment_at(&self.event_layout(cx), x - cx.area().x);
259        let memory = cx.memory::<DurationMemory>();
260        if under.is_some() {
261            memory.hovered = under;
262        }
263        under.or(memory.hovered).unwrap_or(memory.segment).min(last)
264    }
265
266    /// Activates the segment under column `x`; a unit word belongs to the segment before it.
267    fn activate_at(&self, cx: &mut EventCx<'_, Msg>, x: i32) {
268        let offset = x - cx.area().x;
269        let layout = self.event_layout(cx);
270        let index = layout.segments.iter().rposition(|(start, _)| offset >= i32::from(*start)).unwrap_or(0);
271        let memory = cx.memory::<DurationMemory>();
272        memory.segment = index;
273        memory.pending = None;
274    }
275
276    /// Copies, cuts, pastes or selects the whole length.
277    fn apply(&self, cx: &mut EventCx<'_, Msg>, action: EditAction) {
278        let all = std::mem::take(&mut cx.memory::<DurationMemory>().all);
279        match action {
280            EditAction::Cut | EditAction::Copy if all => {
281                cx.copy(parse::write(self.value, self.seconds, cx.env().i18n()));
282                if action == EditAction::Cut {
283                    self.change(cx, 0);
284                }
285            }
286            EditAction::Cut | EditAction::Copy => {}
287            EditAction::Paste => cx.run_action(Scope::Global, "paste"),
288            EditAction::SelectAll => cx.memory::<DurationMemory>().all = true,
289        }
290    }
291
292    /// Sets the length from pasted `text`. Without seconds on screen the pasted seconds are
293    /// dropped and the length keeps its own, as the time field does.
294    fn paste(&self, cx: &mut EventCx<'_, Msg>, text: &str) -> bool {
295        cx.memory::<DurationMemory>().all = false;
296        match parse_duration(text, cx.env().i18n()) {
297            Ok(length) => {
298                let pasted = length.as_secs();
299                let total = if self.seconds { pasted } else { with_part(pasted, 2, parts(self.total())[2]) };
300                self.change(cx, total);
301                true
302            }
303            Err(error) => match &self.on_reject {
304                Some(message) => {
305                    cx.emit(message(error));
306                    true
307                }
308                None => false,
309            },
310        }
311    }
312
313    /// The edit action of a Ctrl chord: A, C and X.
314    fn chord_action(key: &KeyEvent) -> Option<EditAction> {
315        let ctrl = Modifiers { ctrl: true, ..Modifiers::default() };
316        match key.chord.key {
317            Key::Char('a') if key.chord.mods == ctrl => Some(EditAction::SelectAll),
318            Key::Char('c') if key.chord.mods == ctrl => Some(EditAction::Copy),
319            Key::Char('x') if key.chord.mods == ctrl => Some(EditAction::Cut),
320            _ => None,
321        }
322    }
323
324    /// Offers `event` to the edit menu, which opens on a right press or its keys and takes every
325    /// event while open. Returns whether the menu used the event.
326    fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
327        let open = edit_menu::is_open(cx);
328        if !open && !edit_menu::asks(event) {
329            return false;
330        }
331        if let Event::Mouse(mouse) = event
332            && !open
333            && !cx.memory::<DurationMemory>().all
334        {
335            self.activate_at(cx, mouse.x);
336        }
337        let all = cx.memory::<DurationMemory>().all;
338        let (used, chosen) = TextMenu::edit(cx.env(), all, cx.can_paste()).event(cx, event);
339        if used && !open {
340            cx.probe_clipboard();
341        }
342        if let Some(action) = chosen {
343            self.apply(cx, action);
344        }
345        used
346    }
347
348    /// Handles a key while focused.
349    fn key(&self, cx: &mut EventCx<'_, Msg>, key: &KeyEvent) -> bool {
350        let last = self.count() - 1;
351        cx.memory::<DurationMemory>().all = false;
352        let segment = cx.memory::<DurationMemory>().segment.min(last);
353        if key.is_plain(Key::Left) || key.is_plain(Key::Right) {
354            let memory = cx.memory::<DurationMemory>();
355            memory.segment = if key.is_plain(Key::Left) { segment.saturating_sub(1) } else { (segment + 1).min(last) };
356            memory.pending = None;
357            return true;
358        }
359        if key.is_plain(Key::Up) || key.is_plain(Key::Down) {
360            self.step(cx, segment, key.is_plain(Key::Up));
361            return true;
362        }
363        if key.is_plain(Key::Backspace) {
364            cx.memory::<DurationMemory>().pending = None;
365            self.change(cx, with_part(self.total(), segment, 0));
366            return true;
367        }
368        if key.is_plain(Key::Space) || key.text == Some(':') {
369            let memory = cx.memory::<DurationMemory>();
370            memory.segment = (segment + 1).min(last);
371            memory.pending = None;
372            return true;
373        }
374        match key.text {
375            Some(c) if c.is_ascii_digit() => {
376                self.type_digit(cx, u8::try_from(c.to_digit(10).unwrap_or(0)).unwrap_or(0));
377                true
378            }
379            _ => false,
380        }
381    }
382}
383
384impl<Msg: 'static> Widget<Msg> for DurationInput<Msg> {
385    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
386        let full = self.layout(Some(cx.env().i18n()));
387        Size::new(full.width, 1).min(available)
388    }
389
390    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
391        let mut states = if self.active() { cx.states() } else { Vec::new() };
392        if self.invalid {
393            states.push(State::Invalid);
394        }
395        if self.disabled {
396            states.push(State::Disabled);
397        }
398        let focused = states.contains(&State::Focus);
399        let layout = self.fitted(cx.env().i18n(), area.width);
400        // Painting follows every pointer move, so it keeps the last hovered segment: a move onto
401        // a segment remembers it, a move off the field forgets it.
402        let pointer = cx.pointer().map(|(x, _)| Self::segment_at(&layout, x - area.x));
403        let (segment, pending, all) = {
404            let memory = cx.memory::<DurationMemory>();
405            if !focused {
406                *memory = DurationMemory { hovered: memory.hovered, ..DurationMemory::default() };
407            }
408            match pointer {
409                None => memory.hovered = None,
410                Some(Some(index)) => memory.hovered = Some(index),
411                Some(None) => {}
412            }
413            (memory.segment, memory.pending, memory.all)
414        };
415        let selection = cx.style("text-input-selection", None, &states).text();
416        let field_style = cx.style("time-input", None, &states);
417        let surface = field_style.text();
418        let field = Rect::new(area.x, area.y, layout.width.min(area.width), 1);
419        cx.clear(field, surface.bg.unwrap_or_else(|| cx.color("raised")));
420        let pillar = field_style.color("pillar");
421        if self.active() {
422            cx.register_hit(field);
423            edit_menu::request_overlay(cx, field);
424        }
425        let separator = cx.style("time-separator", None, &states).text();
426        // The whole length selected reads as one run of selection, unit words included, without
427        // the active segment standing out.
428        if let Some(bg) = selection.bg.filter(|_| all) {
429            cx.fill(field, bg);
430        }
431        for (x, mark) in &layout.marks {
432            let x = field.x + i32::from(*x);
433            let room = u16::try_from(field.right() - x).unwrap_or(0);
434            let fg = if all { selection.fg.or(separator.fg) } else { separator.fg };
435            cx.text(x, field.y, mark, CellStyle { bg: None, fg, ..separator }, room);
436        }
437        // A zero length rests faint, like an empty field; hover and focus bring the digits back.
438        let resting = !states.iter().any(|state| matches!(state, State::Hover | State::Focus));
439        let empty = self.total() == 0 && resting && !all;
440        let parts = parts(self.total());
441        for (index, (offset, width)) in layout.segments.iter().enumerate() {
442            let x = field.x + i32::from(*offset);
443            let mut segment_states = states.clone();
444            if focused && index == segment && !all {
445                segment_states.push(State::Selected);
446                if pending.is_some() {
447                    segment_states.push(State::Active);
448                }
449            }
450            let mut style = cx.style("time-segment", None, &segment_states).text();
451            if all {
452                style.bg = None;
453                style.fg = selection.fg.or(style.fg);
454            }
455            let rect = Rect::new(x, field.y, *width, 1).intersect(field);
456            if let Some(bg) = style.bg {
457                cx.clear(rect, bg);
458            }
459            let digits = if index == 0 {
460                format!("{:>width$}", parts[0], width = usize::from(width - 2))
461            } else {
462                format!("{:02}", parts[index])
463            };
464            let fg = if empty { separator.fg } else { style.fg.or(surface.fg) };
465            let text_style = CellStyle { bg: None, fg, ..style };
466            cx.text(x + 1, field.y, &digits, text_style, text::width(&digits).min(rect.width.saturating_sub(1)));
467        }
468        // Drawn last so a selected first segment keeps it; the digits never slide.
469        if let Some(color) = pillar {
470            cx.pillar(field.x, field.y, color);
471        }
472    }
473
474    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
475        let all = cx.memory::<DurationMemory>().all;
476        TextMenu::edit(cx.env(), all, cx.can_paste()).paint_overlay(cx, anchor);
477    }
478
479    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
480        if !self.active() {
481            return false;
482        }
483        if self.menu_event(cx, event) {
484            return true;
485        }
486        match event {
487            Event::Key(key) if let Some(action) = Self::chord_action(key) => {
488                self.apply(cx, action);
489                true
490            }
491            Event::Paste(text) => self.paste(cx, text),
492            Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp | MouseKind::ScrollDown) => {
493                let up = mouse.kind == MouseKind::ScrollUp;
494                let segment = self.wheel_target(cx, mouse.x);
495                self.step(cx, segment, up);
496                true
497            }
498            Event::Key(key) => self.key(cx, key),
499            Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Left) => {
500                cx.memory::<DurationMemory>().all = false;
501                self.activate_at(cx, mouse.x);
502                true
503            }
504            _ => false,
505        }
506    }
507
508    fn focusable(&self) -> bool {
509        self.active()
510    }
511}