Skip to main content

qframe/widgets/
time_input.rs

1//! Time of day entry.
2
3use super::edit_menu::{self, EditAction, TextMenu};
4use crate::date::TimeOfDay;
5use crate::event::{Event, KeyEvent, MouseButton, MouseKind};
6use crate::geometry::{Rect, Size};
7use crate::keymap::{Key, Modifiers, Scope};
8use crate::style::CellStyle;
9use crate::text;
10use crate::theme::State;
11use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
12
13/// Cells of one segment: two digits with a cell of room on each side.
14const SEGMENT: u16 = 4;
15
16/// Largest value of the hour, minute and second segments.
17const MAX: [u8; 3] = [TimeOfDay::LARGEST.hour, TimeOfDay::LARGEST.minute, TimeOfDay::LARGEST.second];
18
19/// The hour, minute or second of `time`, by segment index.
20fn part(time: TimeOfDay, index: usize) -> u8 {
21    [time.hour, time.minute, time.second][index]
22}
23
24/// `time` with the segment at `index` set to `value`, capped at the segment's largest value.
25fn with_part(time: TimeOfDay, index: usize, value: u8) -> TimeOfDay {
26    let mut parts = [time.hour, time.minute, time.second];
27    parts[index] = value.min(MAX[index]);
28    TimeOfDay { hour: parts[0], minute: parts[1], second: parts[2] }
29}
30
31/// `hh:mm`, or `hh:mm:ss` with `seconds`: what the field shows and copies.
32fn write(time: TimeOfDay, seconds: bool) -> String {
33    if seconds { time.to_string() } else { format!("{:02}:{:02}", time.hour, time.minute) }
34}
35
36/// Builds a message from a new time.
37type TimeMessage<Msg> = Box<dyn Fn(TimeOfDay) -> Msg>;
38
39/// A time of day edited segment by segment: hours and minutes, optionally seconds, on a 24-hour
40/// clock written the same in every language.
41///
42/// The field takes focus as one control and one segment at a time is active: Left and Right
43/// move between segments, Up and Down change the active segment and wrap around (23 → 00), and
44/// typing digits fills it, moving on once the segment is complete. `:` moves on as well and
45/// Backspace sets the segment to zero. A click activates the segment under the pointer. The wheel changes
46/// the segment under the pointer, one step per notch, without moving focus or the active segment;
47/// over a colon it changes the segment the pointer was last over inside the field, or the active
48/// segment when there was none. The application owns the time.
49///
50/// Ctrl+A selects the whole time; then Ctrl+C copies it as `09:30` (`09:30:00` with seconds) and
51/// Ctrl+X copies it and sets the time to zero. Pasting `9:30` or `09:30:15` sets the time;
52/// other text is ignored. A right click (or Shift+F10 and the menu key) opens the edit menu of
53/// [`TextInput`](super::TextInput) with the same actions; Cut and Copy need the whole time
54/// selected.
55///
56/// Style keys: `time-input` (`bg`, `fg`) with states `hover`, `focus`, `invalid`, `disabled`;
57/// `time-segment` (`bg`, `fg`, `bold`) with `selected` for the active segment and `active` while
58/// a first digit waits for the second; `time-separator` (`fg`); `text-input-selection` (`bg`,
59/// `fg`) for the whole time selected.
60pub struct TimeInput<Msg> {
61    value: TimeOfDay,
62    seconds: bool,
63    invalid: bool,
64    disabled: bool,
65    on_change: Option<TimeMessage<Msg>>,
66}
67
68#[derive(Debug, Default)]
69struct TimeMemory {
70    /// The active segment.
71    segment: usize,
72    /// A first digit typed into the active segment, waiting for the second.
73    pending: Option<u8>,
74    /// Whether the whole time is selected, for copying.
75    all: bool,
76    /// The segment the pointer was last over since it entered the field; the wheel over a colon
77    /// changes it. Forgotten when the pointer leaves the field.
78    hovered: Option<usize>,
79}
80
81impl<Msg> TimeInput<Msg> {
82    /// A field showing `value` as hours and minutes.
83    #[must_use]
84    pub fn new(value: TimeOfDay) -> Self {
85        Self { value, seconds: false, invalid: false, disabled: false, on_change: None }
86    }
87
88    /// Shows and edits seconds too.
89    #[must_use]
90    pub fn seconds(mut self, seconds: bool) -> Self {
91        self.seconds = seconds;
92        self
93    }
94
95    /// Marks the time as failing validation, such as an end before its start.
96    #[must_use]
97    pub fn invalid(mut self, invalid: bool) -> Self {
98        self.invalid = invalid;
99        self
100    }
101
102    /// Greys the field out; it cannot be focused or changed.
103    #[must_use]
104    pub fn disabled(mut self, disabled: bool) -> Self {
105        self.disabled = disabled;
106        self
107    }
108
109    /// Message carrying the new time after every change.
110    #[must_use]
111    pub fn on_change(mut self, message: impl Fn(TimeOfDay) -> Msg + 'static) -> Self {
112        self.on_change = Some(Box::new(message));
113        self
114    }
115
116    fn active(&self) -> bool {
117        !self.disabled && self.on_change.is_some()
118    }
119
120    fn count(&self) -> usize {
121        if self.seconds { 3 } else { 2 }
122    }
123
124    /// Left edge of segment `index`, relative to the field.
125    fn offset(index: usize) -> u16 {
126        // Segments are separated by one colon cell.
127        u16::try_from(index).unwrap_or(0) * (SEGMENT + 1)
128    }
129
130    fn width(&self) -> u16 {
131        Self::offset(self.count() - 1) + SEGMENT
132    }
133
134    fn change(&self, cx: &mut EventCx<'_, Msg>, value: TimeOfDay) {
135        if value != self.value
136            && let Some(message) = &self.on_change
137        {
138            cx.emit(message(value));
139        }
140    }
141
142    /// Types `digit` into the active segment.
143    fn type_digit(&self, cx: &mut EventCx<'_, Msg>, digit: u8) {
144        let last = self.count() - 1;
145        let memory = cx.memory::<TimeMemory>();
146        let segment = memory.segment.min(last);
147        let max = MAX[segment];
148        let joined = memory.pending.map(|first| first * 10 + digit).filter(|joined| *joined <= max);
149        let (value, complete) = match joined {
150            Some(joined) => (joined, true),
151            // A lone digit that cannot start a two-digit value in range is complete by itself.
152            None => (digit, digit * 10 > max),
153        };
154        memory.pending = if complete { None } else { Some(digit) };
155        if complete && segment < last {
156            memory.segment = segment + 1;
157        }
158        self.change(cx, with_part(self.value, segment, value));
159    }
160
161    /// Moves segment `segment` one up or down, wrapping around (23 → 00).
162    fn step(&self, cx: &mut EventCx<'_, Msg>, segment: usize, up: bool) {
163        let last = self.count() - 1;
164        let memory = cx.memory::<TimeMemory>();
165        // A first digit waiting in the active segment stays when the wheel changes another one.
166        if segment == memory.segment.min(last) {
167            memory.pending = None;
168        }
169        let span = u16::from(MAX[segment]) + 1;
170        let delta = if up { 1 } else { span - 1 };
171        let value = (u16::from(part(self.value, segment)) + delta) % span;
172        self.change(cx, with_part(self.value, segment, u8::try_from(value).unwrap_or(0)));
173    }
174
175    /// The segment whose cells include column `offset` of the field; `None` over a colon or past
176    /// the end.
177    fn segment_at(&self, offset: i32) -> Option<usize> {
178        (0..self.count()).find(|index| {
179            let start = i32::from(Self::offset(*index));
180            (start..start + i32::from(SEGMENT)).contains(&offset)
181        })
182    }
183
184    /// The segment the wheel changes at column `x`: the one under the pointer, else the one it
185    /// was last over inside the field, else the active one.
186    fn wheel_target(&self, cx: &mut EventCx<'_, Msg>, x: i32) -> usize {
187        let last = self.count() - 1;
188        let under = self.segment_at(x - cx.area().x);
189        let memory = cx.memory::<TimeMemory>();
190        if under.is_some() {
191            memory.hovered = under;
192        }
193        under.or(memory.hovered).unwrap_or(memory.segment).min(last)
194    }
195
196    /// Activates the segment under column `x`.
197    fn activate_at(&self, cx: &mut EventCx<'_, Msg>, x: i32) {
198        let offset = x - cx.area().x;
199        let index = (0..self.count()).rev().find(|index| offset >= i32::from(Self::offset(*index))).unwrap_or(0);
200        let memory = cx.memory::<TimeMemory>();
201        memory.segment = index;
202        memory.pending = None;
203    }
204
205    /// Copies, cuts, pastes or selects the whole time.
206    fn apply(&self, cx: &mut EventCx<'_, Msg>, action: EditAction) {
207        let all = std::mem::take(&mut cx.memory::<TimeMemory>().all);
208        match action {
209            EditAction::Cut | EditAction::Copy if all => {
210                cx.copy(write(self.value, self.seconds));
211                if action == EditAction::Cut {
212                    self.change(cx, TimeOfDay::default());
213                }
214            }
215            EditAction::Cut | EditAction::Copy => {}
216            EditAction::Paste => cx.run_action(Scope::Global, "paste"),
217            EditAction::SelectAll => cx.memory::<TimeMemory>().all = true,
218        }
219    }
220
221    /// The edit action of a Ctrl chord: A, C and X.
222    fn chord_action(key: &KeyEvent) -> Option<EditAction> {
223        let ctrl = Modifiers { ctrl: true, ..Modifiers::default() };
224        match key.chord.key {
225            Key::Char('a') if key.chord.mods == ctrl => Some(EditAction::SelectAll),
226            Key::Char('c') if key.chord.mods == ctrl => Some(EditAction::Copy),
227            Key::Char('x') if key.chord.mods == ctrl => Some(EditAction::Cut),
228            _ => None,
229        }
230    }
231
232    /// Offers `event` to the edit menu, which opens on a right press or its keys and takes every
233    /// event while open. Returns whether the menu used the event.
234    fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
235        let open = edit_menu::is_open(cx);
236        if !open && !edit_menu::asks(event) {
237            return false;
238        }
239        if let Event::Mouse(mouse) = event
240            && !open
241            && !cx.memory::<TimeMemory>().all
242        {
243            self.activate_at(cx, mouse.x);
244        }
245        let all = cx.memory::<TimeMemory>().all;
246        let (used, chosen) = TextMenu::edit(cx.env(), all, cx.can_paste()).event(cx, event);
247        if used && !open {
248            cx.probe_clipboard();
249        }
250        if let Some(action) = chosen {
251            self.apply(cx, action);
252        }
253        used
254    }
255}
256
257impl<Msg: 'static> Widget<Msg> for TimeInput<Msg> {
258    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
259        Size::new(self.width(), 1).min(available)
260    }
261
262    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
263        let mut states = if self.active() { cx.states() } else { Vec::new() };
264        if self.invalid {
265            states.push(State::Invalid);
266        }
267        if self.disabled {
268            states.push(State::Disabled);
269        }
270        let focused = states.contains(&State::Focus);
271        // Painting follows every pointer move, so it keeps the last hovered segment: a move onto
272        // a segment remembers it, a move off the field forgets it.
273        let pointer = cx.pointer().map(|(x, _)| self.segment_at(x - area.x));
274        let (segment, pending, all) = {
275            let memory = cx.memory::<TimeMemory>();
276            if !focused {
277                *memory = TimeMemory { hovered: memory.hovered, ..TimeMemory::default() };
278            }
279            match pointer {
280                None => memory.hovered = None,
281                Some(Some(index)) => memory.hovered = Some(index),
282                Some(None) => {}
283            }
284            (memory.segment, memory.pending, memory.all)
285        };
286        let selection = cx.style("text-input-selection", None, &states).text();
287        let field_style = cx.style("time-input", None, &states);
288        let surface = field_style.text();
289        let field = Rect::new(area.x, area.y, self.width().min(area.width), 1);
290        cx.clear(field, surface.bg.unwrap_or_else(|| cx.color("raised")));
291        let pillar = field_style.color("pillar");
292        if self.active() {
293            cx.register_hit(field);
294            edit_menu::request_overlay(cx, field);
295        }
296        let separator = cx.style("time-separator", None, &states).text();
297        // The whole time selected reads as one run of selection, colons included, without the
298        // active segment standing out.
299        if let Some(bg) = selection.bg.filter(|_| all) {
300            cx.fill(field, bg);
301        }
302        for index in 0..self.count() {
303            let x = field.x + i32::from(Self::offset(index));
304            if index > 0 {
305                cx.text(x - 1, field.y, ":", CellStyle { bg: None, ..separator }, 1);
306            }
307            let mut segment_states = states.clone();
308            if focused && index == segment && !all {
309                segment_states.push(State::Selected);
310                if pending.is_some() {
311                    segment_states.push(State::Active);
312                }
313            }
314            let mut style = cx.style("time-segment", None, &segment_states).text();
315            if all {
316                style.bg = None;
317                style.fg = selection.fg.or(style.fg);
318            }
319            let rect = Rect::new(x, field.y, SEGMENT, 1).intersect(field);
320            if let Some(bg) = style.bg {
321                cx.clear(rect, bg);
322            }
323            let digits = format!("{:02}", part(self.value, index));
324            let text_style = CellStyle { bg: None, fg: style.fg.or(surface.fg), ..style };
325            cx.text(x + 1, field.y, &digits, text_style, text::width(&digits).min(rect.width.saturating_sub(1)));
326        }
327        // Drawn last so a selected first segment keeps it; the digits never slide.
328        if let Some(color) = pillar {
329            cx.pillar(field.x, field.y, color);
330        }
331    }
332
333    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
334        let all = cx.memory::<TimeMemory>().all;
335        TextMenu::edit(cx.env(), all, cx.can_paste()).paint_overlay(cx, anchor);
336    }
337
338    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
339        if !self.active() {
340            return false;
341        }
342        if self.menu_event(cx, event) {
343            return true;
344        }
345        let last = self.count() - 1;
346        match event {
347            Event::Key(key) if let Some(action) = Self::chord_action(key) => {
348                self.apply(cx, action);
349                true
350            }
351            Event::Paste(text) => {
352                cx.memory::<TimeMemory>().all = false;
353                let Some(time) = TimeOfDay::parse(text) else {
354                    return false;
355                };
356                let time = if self.seconds { time } else { TimeOfDay { second: self.value.second, ..time } };
357                self.change(cx, time);
358                true
359            }
360            Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp | MouseKind::ScrollDown) => {
361                let up = mouse.kind == MouseKind::ScrollUp;
362                let segment = self.wheel_target(cx, mouse.x);
363                self.step(cx, segment, up);
364                true
365            }
366            Event::Key(key) => {
367                cx.memory::<TimeMemory>().all = false;
368                let segment = cx.memory::<TimeMemory>().segment.min(last);
369                if key.is_plain(Key::Left) || key.is_plain(Key::Right) {
370                    let memory = cx.memory::<TimeMemory>();
371                    memory.segment =
372                        if key.is_plain(Key::Left) { segment.saturating_sub(1) } else { (segment + 1).min(last) };
373                    memory.pending = None;
374                    return true;
375                }
376                if key.is_plain(Key::Up) || key.is_plain(Key::Down) {
377                    self.step(cx, segment, key.is_plain(Key::Up));
378                    return true;
379                }
380                if key.is_plain(Key::Backspace) {
381                    cx.memory::<TimeMemory>().pending = None;
382                    self.change(cx, with_part(self.value, segment, 0));
383                    return true;
384                }
385                match key.text {
386                    Some(':') => {
387                        let memory = cx.memory::<TimeMemory>();
388                        memory.segment = (segment + 1).min(last);
389                        memory.pending = None;
390                        true
391                    }
392                    Some(c) if c.is_ascii_digit() => {
393                        self.type_digit(cx, u8::try_from(c.to_digit(10).unwrap_or(0)).unwrap_or(0));
394                        true
395                    }
396                    _ => false,
397                }
398            }
399            Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Left) => {
400                cx.memory::<TimeMemory>().all = false;
401                self.activate_at(cx, mouse.x);
402                true
403            }
404            _ => false,
405        }
406    }
407
408    fn focusable(&self) -> bool {
409        self.active()
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::runtime::{App, Command, Harness};
417    use crate::widget::View;
418
419    struct Demo {
420        time: TimeOfDay,
421        seconds: bool,
422        disabled: bool,
423    }
424
425    impl App for Demo {
426        type Msg = TimeOfDay;
427        fn update(&mut self, time: TimeOfDay) -> Command<TimeOfDay> {
428            self.time = time;
429            Command::none()
430        }
431        fn view(&self, ui: &mut View<'_, TimeOfDay>) {
432            ui.add(TimeInput::new(self.time).seconds(self.seconds).disabled(self.disabled).on_change(|t| t))
433                .id("starts");
434        }
435    }
436
437    fn harness(hour: u8, minute: u8, seconds: bool) -> Harness<Demo> {
438        Harness::new(Demo { time: TimeOfDay::new(hour, minute, 0), seconds, disabled: false }, 20, 1)
439    }
440
441    #[test]
442    fn draws_segments_with_a_faint_colon_and_raises_the_active_one() {
443        let mut h = harness(9, 5, false);
444        assert_eq!(h.screen(), " 09 : 05\n");
445        let theme = h.env().theme();
446        assert_eq!(h.fg(4, 0), theme.color("muted"));
447        let idle = h.bg(1, 0);
448        h.press("tab");
449        assert_ne!(h.bg(1, 0), h.bg(6, 0), "the active segment stands out");
450        assert_ne!(h.bg(1, 0), idle);
451        h.press("right");
452        assert_eq!(h.bg(1, 0), h.bg(4, 0), "the first segment is back on the field surface");
453        let mut h = harness(23, 59, true);
454        assert_eq!(h.screen(), " 23 : 59 : 00\n");
455        h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
456        assert!(h.screen().is_ascii());
457    }
458
459    #[test]
460    fn arrows_move_between_segments_and_wrap_values() {
461        let mut h = harness(23, 0, false);
462        h.press("tab").press("up");
463        assert_eq!(h.app().time, TimeOfDay::new(0, 0, 0));
464        h.press("down");
465        assert_eq!(h.app().time, TimeOfDay::new(23, 0, 0));
466        h.press("right").press("down");
467        assert_eq!(h.app().time, TimeOfDay::new(23, 59, 0));
468        h.press("right").press("up");
469        assert_eq!(h.app().time, TimeOfDay::new(23, 0, 0), "right stops at the last segment");
470    }
471
472    #[test]
473    fn typing_fills_segments_and_moves_on() {
474        let mut h = harness(0, 0, true);
475        h.press("tab").type_text("0930");
476        assert_eq!(h.app().time, TimeOfDay::new(9, 30, 0));
477        h.press("left").press("left").type_text("7");
478        assert_eq!(h.app().time, TimeOfDay::new(7, 30, 0), "7 cannot start an hour, so it completes it");
479        h.type_text("45").type_text("2");
480        assert_eq!(h.app().time, TimeOfDay::new(7, 45, 2));
481        h.type_text("9");
482        assert_eq!(h.app().time, TimeOfDay::new(7, 45, 29));
483        h.press("left").press("left").type_text("24");
484        assert_eq!(h.app().time, TimeOfDay::new(4, 45, 29), "24 is no hour; the 4 stands alone");
485        h.press("backspace");
486        assert_eq!(h.app().time, TimeOfDay::new(4, 0, 29), "the minute became active after the 4");
487    }
488
489    #[test]
490    fn the_wheel_changes_the_segment_under_the_pointer() {
491        let mut h = harness(8, 15, false);
492        h.click(6, 0);
493        h.mouse(MouseKind::ScrollUp, 6, 0).mouse(MouseKind::ScrollUp, 6, 0);
494        assert_eq!(h.app().time, TimeOfDay::new(8, 17, 0), "one minute per notch");
495        h.mouse(MouseKind::ScrollDown, 6, 0);
496        assert_eq!(h.app().time, TimeOfDay::new(8, 16, 0));
497    }
498
499    /// The active segment's background, to show the wheel leaves it where it was.
500    fn active_marks(h: &Harness<Demo>) -> (Option<crate::color::Rgb>, Option<crate::color::Rgb>) {
501        (h.bg(1, 0), h.bg(6, 0))
502    }
503
504    #[test]
505    fn the_wheel_over_a_segment_changes_it_whatever_is_active() {
506        let mut h = Harness::new(Demo { time: TimeOfDay::new(8, 15, 0), seconds: false, disabled: false }, 20, 3);
507        h.click(6, 0);
508        let before = active_marks(&h);
509        h.hover(1, 0).mouse(MouseKind::ScrollUp, 1, 0);
510        assert_eq!(h.app().time, TimeOfDay::new(9, 15, 0), "hours change while the minutes are active");
511        h.mouse(MouseKind::ScrollDown, 2, 0).mouse(MouseKind::ScrollDown, 2, 0);
512        assert_eq!(h.app().time, TimeOfDay::new(7, 15, 0), "one hour per notch");
513        h.press("up");
514        assert_eq!(h.app().time, TimeOfDay::new(7, 16, 0), "the keys still change the active minutes");
515        assert_eq!(active_marks(&h), before, "the active segment and focus stay");
516        // Unfocused, the wheel over the minutes changes them without taking focus.
517        h.click(1, 2);
518        let idle = active_marks(&h);
519        h.hover(7, 0).mouse(MouseKind::ScrollUp, 7, 0);
520        assert_eq!(h.app().time, TimeOfDay::new(7, 17, 0));
521        h.hover(0, 0).mouse(MouseKind::ScrollDown, 0, 0);
522        assert_eq!(h.app().time, TimeOfDay::new(6, 17, 0), "the pillar cell belongs to the hours");
523        h.hover(1, 2);
524        assert_eq!(active_marks(&h), idle, "the field did not take focus");
525    }
526
527    #[test]
528    fn the_wheel_over_a_colon_changes_the_segment_last_hovered() {
529        let mut h = Harness::new(Demo { time: TimeOfDay::new(23, 59, 0), seconds: false, disabled: false }, 20, 3);
530        h.click(1, 0);
531        h.hover(6, 0).hover(4, 0).mouse(MouseKind::ScrollUp, 4, 0);
532        assert_eq!(h.app().time, TimeOfDay::new(23, 0, 0), "the minutes wrap like the keys");
533        h.hover(2, 0).hover(4, 0).mouse(MouseKind::ScrollUp, 4, 0);
534        assert_eq!(h.app().time, TimeOfDay::new(0, 0, 0), "the hours wrap like the keys");
535        // Leaving the field forgets the last hover: the colon then changes the active hours.
536        h.hover(6, 0).hover(6, 2).hover(4, 0).mouse(MouseKind::ScrollDown, 4, 0);
537        assert_eq!(h.app().time, TimeOfDay::new(23, 0, 0));
538    }
539
540    #[test]
541    fn the_wheel_over_a_colon_without_a_hover_changes_the_active_segment() {
542        let mut h = Harness::new(Demo { time: TimeOfDay::new(8, 15, 30), seconds: true, disabled: false }, 20, 3);
543        h.press("tab").press("right").press("right");
544        h.mouse(MouseKind::ScrollUp, 4, 0);
545        assert_eq!(h.app().time, TimeOfDay::new(8, 15, 31), "straight onto the colon: the active seconds");
546        h.hover(11, 0).hover(9, 0).mouse(MouseKind::ScrollUp, 9, 0);
547        assert_eq!(h.app().time, TimeOfDay::new(8, 15, 32), "last over the seconds");
548    }
549
550    #[test]
551    fn the_wheel_does_nothing_on_a_disabled_field() {
552        let mut h = Harness::new(Demo { time: TimeOfDay::new(8, 15, 0), seconds: false, disabled: true }, 20, 1);
553        h.hover(6, 0).mouse(MouseKind::ScrollUp, 6, 0).mouse(MouseKind::ScrollUp, 1, 0);
554        assert_eq!(h.app().time, TimeOfDay::new(8, 15, 0));
555    }
556
557    #[test]
558    fn select_all_copies_cuts_and_pastes_the_whole_time() {
559        let mut h = Harness::new(Demo { time: TimeOfDay::new(9, 30, 0), seconds: false, disabled: false }, 30, 6);
560        h.set_reduced_motion(true);
561        h.press("tab").press("ctrl+c");
562        assert!(h.copied().is_empty(), "nothing is selected yet");
563        let idle = h.bg(6, 0);
564        h.press("ctrl+a");
565        let selection = h.env().theme().style("text-input-selection", None, &[]).paint("bg").map(|paint| paint.at(0.0));
566        assert_ne!(h.bg(6, 0), idle, "every segment shows the selection");
567        assert_eq!((h.bg(1, 0), h.bg(4, 0), h.bg(6, 0)), (selection, selection, selection), "colon included");
568        h.press("ctrl+c");
569        assert_eq!(h.clipboard(), Some("09:30"));
570        h.press("ctrl+a").press("ctrl+x");
571        assert_eq!(h.app().time, TimeOfDay::new(0, 0, 0));
572        h.paste("7:45");
573        assert_eq!(h.app().time, TimeOfDay::new(7, 45, 0));
574        h.paste("25:00").paste("noon");
575        assert_eq!(h.app().time, TimeOfDay::new(7, 45, 0), "text that is no time is ignored");
576    }
577
578    #[test]
579    fn right_click_opens_the_edit_menu() {
580        let mut h = Harness::new(Demo { time: TimeOfDay::new(9, 30, 0), seconds: true, disabled: false }, 30, 6);
581        h.set_reduced_motion(true);
582        h.mouse(MouseKind::Down(MouseButton::Right), 6, 0).mouse(MouseKind::Up(MouseButton::Right), 6, 0);
583        assert!(h.screen().contains("Select all"), "{}", h.screen());
584        let muted = h.env().theme().color("muted");
585        assert_eq!(h.fg(8, 2), muted, "Copy needs the whole time selected: {}", h.screen());
586        h.click_text("Select all");
587        h.mouse(MouseKind::Down(MouseButton::Right), 6, 0).mouse(MouseKind::Up(MouseButton::Right), 6, 0);
588        h.click_text("Copy");
589        assert_eq!(h.clipboard(), Some("09:30:00"));
590        h.set_system_clipboard(Some("18:05:30")).mouse(MouseKind::Down(MouseButton::Right), 1, 0);
591        h.click_text("Paste");
592        assert_eq!(h.app().time, TimeOfDay::new(18, 5, 30));
593    }
594
595    #[test]
596    fn click_activates_a_segment_and_disabled_ignores_input() {
597        let mut h = harness(8, 15, false);
598        h.click(6, 0).press("up");
599        assert_eq!(h.app().time, TimeOfDay::new(8, 16, 0));
600        let mut h = Harness::new(Demo { time: TimeOfDay::new(8, 15, 0), seconds: false, disabled: true }, 20, 1);
601        h.press("tab").press("up").click(1, 0).type_text("1");
602        assert_eq!(h.app().time, TimeOfDay::new(8, 15, 0));
603        assert_eq!(h.fg(1, 0), h.env().theme().color("muted"));
604    }
605}