Skip to main content

gpui_base/
time_field.rs

1use crate::TestSupportExt as _;
2use std::rc::Rc;
3
4use chrono::{NaiveTime, Timelike as _};
5use gpui::{
6    AnyElement, App, Context, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable,
7    InteractiveElement, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent,
8    ParentElement, Render, RenderOnce, Role, SharedString, StatefulInteractiveElement,
9    StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
10};
11
12use crate::{
13    Decrement, Increment, StyledExt as _,
14    a11y_disabled::A11yDisabledExt as _,
15    actions::{SelectLeft, SelectNextColumn, SelectPrevColumn, SelectRight},
16    input::Delete,
17};
18
19const CONTEXT: &str = "TimeField";
20
21pub(crate) fn init(cx: &mut App) {
22    cx.bind_keys([
23        KeyBinding::new("up", Increment, Some(CONTEXT)),
24        KeyBinding::new("down", Decrement, Some(CONTEXT)),
25        KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
26        KeyBinding::new("right", SelectRight, Some(CONTEXT)),
27        KeyBinding::new("tab", SelectNextColumn, Some(CONTEXT)),
28        KeyBinding::new("shift-tab", SelectPrevColumn, Some(CONTEXT)),
29        KeyBinding::new("backspace", Delete, Some(CONTEXT)),
30        KeyBinding::new("delete", Delete, Some(CONTEXT)),
31    ]);
32}
33
34/// The smallest unit a [`TimeField`] edits.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
36pub enum TimePrecision {
37    /// Hours and minutes, e.g. `09:30`.
38    #[default]
39    Minute,
40    /// Hours, minutes and seconds, e.g. `09:30:15`.
41    Second,
42}
43
44impl TimePrecision {
45    /// The `chrono` format that displays a time at this precision and cycle.
46    pub fn format(self, hour_cycle: HourCycle) -> &'static str {
47        match (self, hour_cycle) {
48            (Self::Minute, HourCycle::H23) => "%H:%M",
49            (Self::Second, HourCycle::H23) => "%H:%M:%S",
50            (Self::Minute, HourCycle::H12) => "%I:%M %p",
51            (Self::Second, HourCycle::H12) => "%I:%M:%S %p",
52        }
53    }
54
55    /// Drop the components finer than this precision.
56    pub fn truncate(self, time: NaiveTime) -> NaiveTime {
57        let second = match self {
58            Self::Minute => 0,
59            Self::Second => time.second(),
60        };
61        NaiveTime::from_hms_opt(time.hour(), time.minute(), second).unwrap_or(time)
62    }
63}
64
65/// How a [`TimeField`] counts the hours of a day.
66///
67/// The names follow the Unicode `hourCycle` values.
68#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
69pub enum HourCycle {
70    /// A 24-hour clock from `00` to `23`.
71    #[default]
72    H23,
73    /// A 12-hour clock from `12` to `11`, with an AM/PM segment.
74    H12,
75}
76
77/// One editable component of a [`TimeField`].
78#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
79pub enum TimeSegment {
80    Hour,
81    Minute,
82    Second,
83    /// AM or PM, present with [`HourCycle::H12`].
84    Period,
85}
86
87impl TimeSegment {
88    fn name(self) -> &'static str {
89        match self {
90            Self::Hour => "hour",
91            Self::Minute => "minute",
92            Self::Second => "second",
93            Self::Period => "period",
94        }
95    }
96}
97
98/// A semantic notification from a [`TimeFieldState`].
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum TimeFieldEvent {
101    /// Keyboard editing changed the time.
102    Change(NaiveTime),
103}
104
105/// The pure editing rules of a [`TimeFieldState`], kept apart from focus so
106/// they can be tested without a window.
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108struct SegmentEditor {
109    time: NaiveTime,
110    precision: TimePrecision,
111    hour_cycle: HourCycle,
112    segment: TimeSegment,
113    /// The first digit typed into the selected segment, awaiting a second.
114    pending_digit: Option<u32>,
115}
116
117impl SegmentEditor {
118    fn new() -> Self {
119        Self {
120            time: NaiveTime::MIN,
121            precision: TimePrecision::default(),
122            hour_cycle: HourCycle::default(),
123            segment: TimeSegment::Hour,
124            pending_digit: None,
125        }
126    }
127
128    /// The segments shown, in reading order.
129    fn segments(&self) -> &'static [TimeSegment] {
130        use TimeSegment::*;
131        match (self.precision, self.hour_cycle) {
132            (TimePrecision::Minute, HourCycle::H23) => &[Hour, Minute],
133            (TimePrecision::Second, HourCycle::H23) => &[Hour, Minute, Second],
134            (TimePrecision::Minute, HourCycle::H12) => &[Hour, Minute, Period],
135            (TimePrecision::Second, HourCycle::H12) => &[Hour, Minute, Second, Period],
136        }
137    }
138
139    /// The inclusive range a segment's displayed value takes.
140    fn bounds(&self, segment: TimeSegment) -> (u32, u32) {
141        match (segment, self.hour_cycle) {
142            (TimeSegment::Hour, HourCycle::H23) => (0, 23),
143            (TimeSegment::Hour, HourCycle::H12) => (1, 12),
144            (TimeSegment::Minute | TimeSegment::Second, _) => (0, 59),
145            (TimeSegment::Period, _) => (0, 1),
146        }
147    }
148
149    /// The displayed value of a segment: the hour follows the cycle, and the
150    /// period is `0` for AM and `1` for PM.
151    fn value(&self, segment: TimeSegment) -> u32 {
152        let hour = self.time.hour();
153        match (segment, self.hour_cycle) {
154            (TimeSegment::Hour, HourCycle::H23) => hour,
155            (TimeSegment::Hour, HourCycle::H12) => (hour + 11) % 12 + 1,
156            (TimeSegment::Minute, _) => self.time.minute(),
157            (TimeSegment::Second, _) => self.time.second(),
158            (TimeSegment::Period, _) => hour / 12,
159        }
160    }
161
162    fn label(&self, segment: TimeSegment) -> SharedString {
163        match segment {
164            TimeSegment::Period if self.value(segment) == 0 => "AM".into(),
165            TimeSegment::Period => "PM".into(),
166            _ => format!("{:02}", self.value(segment)).into(),
167        }
168    }
169
170    fn with_value(&self, segment: TimeSegment, value: u32) -> NaiveTime {
171        let time = self.time;
172        let pm_offset = time.hour() / 12 * 12;
173        match (segment, self.hour_cycle) {
174            (TimeSegment::Hour, HourCycle::H23) => time.with_hour(value),
175            // 12 AM is midnight and 12 PM is noon.
176            (TimeSegment::Hour, HourCycle::H12) => time.with_hour(value % 12 + pm_offset),
177            (TimeSegment::Minute, _) => time.with_minute(value),
178            (TimeSegment::Second, _) => time.with_second(value),
179            (TimeSegment::Period, _) => time.with_hour(time.hour() % 12 + value * 12),
180        }
181        .unwrap_or(time)
182    }
183
184    fn set_precision(&mut self, precision: TimePrecision) {
185        self.precision = precision;
186        self.time = precision.truncate(self.time);
187        self.reset_segment();
188    }
189
190    fn set_hour_cycle(&mut self, hour_cycle: HourCycle) {
191        self.hour_cycle = hour_cycle;
192        self.reset_segment();
193    }
194
195    fn reset_segment(&mut self) {
196        if !self.segments().contains(&self.segment) {
197            self.segment = TimeSegment::Hour;
198        }
199        self.pending_digit = None;
200    }
201
202    fn set_time(&mut self, time: NaiveTime) -> bool {
203        let time = self.precision.truncate(time);
204        if self.time == time {
205            // Keep a half-typed segment when the owner echoes the same value back.
206            return false;
207        }
208        self.time = time;
209        self.pending_digit = None;
210        true
211    }
212
213    fn select_segment(&mut self, segment: TimeSegment) -> bool {
214        if !self.segments().contains(&segment) {
215            return false;
216        }
217        self.segment = segment;
218        self.pending_digit = None;
219        true
220    }
221
222    /// Move the selected segment by `offset`; returns `false` at either end.
223    fn move_segment(&mut self, offset: isize) -> bool {
224        let segments = self.segments();
225        let ix = segments
226            .iter()
227            .position(|segment| *segment == self.segment)
228            .unwrap_or(0);
229        let Some(next) = ix
230            .checked_add_signed(offset)
231            .and_then(|ix| segments.get(ix))
232        else {
233            return false;
234        };
235        self.select_segment(*next)
236    }
237
238    /// Step the selected segment by `delta`, wrapping within the segment.
239    fn step(&mut self, delta: i32) -> bool {
240        self.pending_digit = None;
241        let (min, max) = self.bounds(self.segment);
242        let span = (max - min + 1) as i32;
243        let value = (self.value(self.segment) as i32 - min as i32 + delta).rem_euclid(span);
244        self.replace_segment(value as u32 + min)
245    }
246
247    fn input_digit(&mut self, digit: u32) -> bool {
248        if self.segment == TimeSegment::Period {
249            return false;
250        }
251        let (min, max) = self.bounds(self.segment);
252        let (value, complete) = match self.pending_digit.take() {
253            Some(first) if (min..=max).contains(&(first * 10 + digit)) => {
254                (first * 10 + digit, true)
255            }
256            // The two digits cannot form a valid value, so the new digit starts over.
257            _ => (digit, digit * 10 > max),
258        };
259        if !complete {
260            self.pending_digit = Some(digit);
261        }
262        let changed = self.replace_segment(value);
263        if complete {
264            self.move_segment(1);
265        }
266        changed
267    }
268
269    /// Type `a` or `p` into the period segment.
270    fn input_period(&mut self, pm: bool) -> bool {
271        if self.segment != TimeSegment::Period {
272            return false;
273        }
274        self.replace_segment(pm as u32)
275    }
276
277    /// Reset the selected segment to its first value: zero, or `12` / AM on a
278    /// 12-hour clock.
279    fn clear_segment(&mut self) -> bool {
280        self.pending_digit = None;
281        self.replace_segment(0)
282    }
283
284    fn replace_segment(&mut self, value: u32) -> bool {
285        let time = self.with_value(self.segment, value);
286        let changed = time != self.time;
287        self.time = time;
288        changed
289    }
290}
291
292/// Retained editing behavior for a segmented time field.
293///
294/// The field is one Tab stop. Inside it, one segment is selected at a time:
295/// Up/Down step the selected segment and wrap within it (no carry into the
296/// next unit), Left/Right and Tab/Shift-Tab move between segments, digits are
297/// typed with a two-digit buffer that advances to the next segment once no
298/// further digit could fit, `a`/`p` set the AM/PM segment, and
299/// Backspace/Delete reset the segment.
300///
301/// The owner supplies the value with [`TimeFieldState::set_time`], which does
302/// not emit; user edits emit [`TimeFieldEvent::Change`].
303pub struct TimeFieldState {
304    focus_handle: FocusHandle,
305    editor: SegmentEditor,
306}
307
308impl TimeFieldState {
309    pub fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
310        Self {
311            focus_handle: cx.focus_handle().tab_stop(true),
312            editor: SegmentEditor::new(),
313        }
314    }
315
316    /// Set the precision, default: [`TimePrecision::Minute`].
317    pub fn precision(mut self, precision: TimePrecision) -> Self {
318        self.editor.set_precision(precision);
319        self
320    }
321
322    pub fn set_precision(
323        &mut self,
324        precision: TimePrecision,
325        _: &mut Window,
326        cx: &mut Context<Self>,
327    ) {
328        if self.editor.precision != precision {
329            self.editor.set_precision(precision);
330            cx.notify();
331        }
332    }
333
334    /// Set the hour cycle, default: [`HourCycle::H23`].
335    pub fn hour_cycle(mut self, hour_cycle: HourCycle) -> Self {
336        self.editor.set_hour_cycle(hour_cycle);
337        self
338    }
339
340    pub fn set_hour_cycle(
341        &mut self,
342        hour_cycle: HourCycle,
343        _: &mut Window,
344        cx: &mut Context<Self>,
345    ) {
346        if self.editor.hour_cycle != hour_cycle {
347            self.editor.set_hour_cycle(hour_cycle);
348            cx.notify();
349        }
350    }
351
352    pub fn time(&self) -> NaiveTime {
353        self.editor.time
354    }
355
356    /// Replace the time without emitting [`TimeFieldEvent::Change`].
357    pub fn set_time(&mut self, time: NaiveTime, _: &mut Window, cx: &mut Context<Self>) {
358        if self.editor.set_time(time) {
359            cx.notify();
360        }
361    }
362
363    /// The segment that keyboard editing applies to.
364    pub fn selected_segment(&self) -> TimeSegment {
365        self.editor.segment
366    }
367
368    pub fn focus(&self, window: &mut Window, cx: &mut App) {
369        self.focus_handle.focus(window, cx);
370    }
371
372    fn edit(&mut self, cx: &mut Context<Self>, edit: impl FnOnce(&mut SegmentEditor) -> bool) {
373        if edit(&mut self.editor) {
374            cx.emit(TimeFieldEvent::Change(self.editor.time));
375        }
376        cx.notify();
377    }
378
379    fn on_increment(&mut self, _: &Increment, _: &mut Window, cx: &mut Context<Self>) {
380        self.edit(cx, |editor| editor.step(1));
381    }
382
383    fn on_decrement(&mut self, _: &Decrement, _: &mut Window, cx: &mut Context<Self>) {
384        self.edit(cx, |editor| editor.step(-1));
385    }
386
387    fn on_delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context<Self>) {
388        self.edit(cx, SegmentEditor::clear_segment);
389    }
390
391    fn on_select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
392        self.editor.move_segment(-1);
393        cx.notify();
394    }
395
396    fn on_select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
397        self.editor.move_segment(1);
398        cx.notify();
399    }
400
401    // Tab walks the segments first and leaves the field only from the last one.
402    fn on_next_column(&mut self, _: &SelectNextColumn, _: &mut Window, cx: &mut Context<Self>) {
403        if self.editor.move_segment(1) {
404            cx.notify();
405        } else {
406            cx.propagate();
407        }
408    }
409
410    fn on_prev_column(&mut self, _: &SelectPrevColumn, _: &mut Window, cx: &mut Context<Self>) {
411        if self.editor.move_segment(-1) {
412            cx.notify();
413        } else {
414            cx.propagate();
415        }
416    }
417
418    fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
419        let keystroke = &event.keystroke;
420        if keystroke.modifiers.modified() || keystroke.key.chars().count() != 1 {
421            return;
422        }
423        let Some(ch) = keystroke.key.chars().next() else {
424            return;
425        };
426        let edit: Box<dyn FnOnce(&mut SegmentEditor) -> bool> = match ch {
427            '0'..='9' => Box::new(move |editor| editor.input_digit(ch as u32 - '0' as u32)),
428            'a' | 'A' => Box::new(|editor| editor.input_period(false)),
429            'p' | 'P' => Box::new(|editor| editor.input_period(true)),
430            _ => return,
431        };
432        window.prevent_default();
433        cx.stop_propagation();
434        self.edit(cx, edit);
435    }
436
437    fn select_segment(&mut self, segment: TimeSegment, cx: &mut Context<Self>) {
438        self.editor.select_segment(segment);
439        cx.notify();
440    }
441}
442
443impl Focusable for TimeFieldState {
444    fn focus_handle(&self, _: &App) -> FocusHandle {
445        self.focus_handle.clone()
446    }
447}
448impl EventEmitter<TimeFieldEvent> for TimeFieldState {}
449impl Render for TimeFieldState {
450    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
451        Empty
452    }
453}
454
455/// State exposed to a [`TimeField`] segment slot for decoration.
456#[derive(Clone, Copy, Debug)]
457pub struct TimeFieldSegmentState {
458    segment: TimeSegment,
459    value: u32,
460    selected: bool,
461    disabled: bool,
462}
463
464impl TimeFieldSegmentState {
465    pub fn segment(&self) -> TimeSegment {
466        self.segment
467    }
468
469    /// The displayed value: the hour on the configured clock, the minute or
470    /// second, or `0` for AM and `1` for PM.
471    pub fn value(&self) -> u32 {
472        self.value
473    }
474
475    /// Whether keyboard editing applies to this segment: it is the selected
476    /// segment and the field has focus.
477    pub fn is_selected(&self) -> bool {
478        self.selected
479    }
480
481    pub fn is_disabled(&self) -> bool {
482        self.disabled
483    }
484}
485
486type SegmentRenderer =
487    Rc<dyn Fn(TimeFieldSegment, TimeFieldSegmentState, &mut Window, &mut App) -> AnyElement>;
488
489/// An unstyled, pre-wired segment passed to the [`TimeField`] segment slot.
490#[derive(IntoElement)]
491pub struct TimeFieldSegment {
492    base: crate::ObservedElement<gpui::Stateful<gpui::Div>>,
493    style: StyleRefinement,
494    children: Vec<AnyElement>,
495}
496
497impl TimeFieldSegment {
498    /// Remove the default label so a styled facade can provide custom content.
499    pub fn clear_children(mut self) -> Self {
500        self.children.clear();
501        self
502    }
503}
504
505impl ParentElement for TimeFieldSegment {
506    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
507        self.children.extend(elements);
508    }
509}
510impl Styled for TimeFieldSegment {
511    fn style(&mut self) -> &mut StyleRefinement {
512        &mut self.style
513    }
514}
515impl InteractiveElement for TimeFieldSegment {
516    fn interactivity(&mut self) -> &mut gpui::Interactivity {
517        self.base.interactivity()
518    }
519}
520impl StatefulInteractiveElement for TimeFieldSegment {}
521impl RenderOnce for TimeFieldSegment {
522    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
523        self.base.children(self.children).refine_style(&self.style)
524    }
525}
526
527/// Unstyled segmented time editor.
528///
529/// Base owns focus, the keyboard model and pointer segment selection; the
530/// presentation lays out and decorates the field with `Styled` and each
531/// segment through [`TimeField::render_segment`]. A `:` separates the numeric
532/// segments; the AM/PM segment follows without one.
533#[derive(IntoElement)]
534pub struct TimeField {
535    id: ElementId,
536    state: Entity<TimeFieldState>,
537    disabled: bool,
538    style: StyleRefinement,
539    segment: SegmentRenderer,
540}
541
542impl TimeField {
543    pub fn new(id: impl Into<ElementId>, state: &Entity<TimeFieldState>) -> Self {
544        Self {
545            id: id.into(),
546            state: state.clone(),
547            disabled: false,
548            style: StyleRefinement::default(),
549            segment: Rc::new(|segment, _, _, _| segment.into_any_element()),
550        }
551    }
552
553    pub fn disabled(mut self, disabled: bool) -> Self {
554        self.disabled = disabled;
555        self
556    }
557
558    /// Decorate each segment. The segment already carries its label: two
559    /// digits, or `AM`/`PM`.
560    pub fn render_segment(
561        mut self,
562        render: impl Fn(TimeFieldSegment, TimeFieldSegmentState, &mut Window, &mut App) -> AnyElement
563        + 'static,
564    ) -> Self {
565        self.segment = Rc::new(render);
566        self
567    }
568}
569
570impl Styled for TimeField {
571    fn style(&mut self) -> &mut StyleRefinement {
572        &mut self.style
573    }
574}
575
576impl RenderOnce for TimeField {
577    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
578        let disabled = self.disabled;
579        let state = self.state.read(cx);
580        let focus_handle = state.focus_handle.clone();
581        let focused = focus_handle.is_focused(window);
582        let editor = state.editor;
583        let format = editor.precision.format(editor.hour_cycle);
584
585        let mut root = div()
586            .id(self.id.clone())
587            .test_support()
588            .role(Role::TimeInput)
589            .aria_value(SharedString::from(editor.time.format(format).to_string()))
590            .track_focus(&focus_handle.clone().tab_stop(!disabled))
591            .when(!disabled, |this| {
592                this.key_context(CONTEXT)
593                    .on_action(window.listener_for(&self.state, TimeFieldState::on_increment))
594                    .on_action(window.listener_for(&self.state, TimeFieldState::on_decrement))
595                    .on_action(window.listener_for(&self.state, TimeFieldState::on_delete))
596                    .on_action(window.listener_for(&self.state, TimeFieldState::on_select_left))
597                    .on_action(window.listener_for(&self.state, TimeFieldState::on_select_right))
598                    .on_action(window.listener_for(&self.state, TimeFieldState::on_next_column))
599                    .on_action(window.listener_for(&self.state, TimeFieldState::on_prev_column))
600                    .on_key_down(window.listener_for(&self.state, TimeFieldState::on_key_down))
601            });
602
603        for (ix, segment) in editor.segments().iter().copied().enumerate() {
604            if ix > 0 && segment != TimeSegment::Period {
605                root = root.child(div().child(":"));
606            }
607            let segment_state = TimeFieldSegmentState {
608                segment,
609                value: editor.value(segment),
610                selected: focused && segment == editor.segment,
611                disabled,
612            };
613            let mut item = TimeFieldSegment {
614                base: div().id(segment.name()).test_support(),
615                style: StyleRefinement::default(),
616                children: vec![],
617            }
618            .child(editor.label(segment));
619            if !disabled {
620                let state = self.state.clone();
621                let focus_handle = focus_handle.clone();
622                item =
623                    item.on_mouse_down(MouseButton::Left, move |_: &MouseDownEvent, window, cx| {
624                        focus_handle.focus(window, cx);
625                        state.update(cx, |state, cx| state.select_segment(segment, cx));
626                    });
627            }
628            root = root.child((self.segment)(item, segment_state, window, cx));
629        }
630
631        root.refine_style(&self.style).a11y_disabled(disabled)
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    fn hms(h: u32, m: u32, s: u32) -> NaiveTime {
640        NaiveTime::from_hms_opt(h, m, s).unwrap()
641    }
642
643    fn editor(precision: TimePrecision, hour_cycle: HourCycle) -> SegmentEditor {
644        let mut editor = SegmentEditor::new();
645        editor.set_precision(precision);
646        editor.set_hour_cycle(hour_cycle);
647        editor
648    }
649
650    fn type_keys(editor: &mut SegmentEditor, keys: &str) {
651        for ch in keys.chars() {
652            match ch {
653                'a' => editor.input_period(false),
654                'p' => editor.input_period(true),
655                _ => editor.input_digit(ch.to_digit(10).unwrap()),
656            };
657        }
658    }
659
660    #[test]
661    fn typing_fills_segments_and_advances() {
662        let mut editor = editor(TimePrecision::Second, HourCycle::H23);
663        type_keys(&mut editor, "093015");
664        assert_eq!(editor.time, hms(9, 30, 15));
665        // The last segment stays selected once it is complete.
666        assert_eq!(editor.segment, TimeSegment::Second);
667    }
668
669    #[test]
670    fn a_digit_that_cannot_start_two_digits_completes_the_segment() {
671        let mut editor = editor(TimePrecision::Minute, HourCycle::H23);
672        type_keys(&mut editor, "7");
673        assert_eq!(editor.time, hms(7, 0, 0));
674        assert_eq!(editor.segment, TimeSegment::Minute);
675        type_keys(&mut editor, "8");
676        assert_eq!(editor.time, hms(7, 8, 0));
677    }
678
679    #[test]
680    fn an_out_of_range_pair_restarts_from_the_second_digit() {
681        let mut editor = editor(TimePrecision::Minute, HourCycle::H23);
682        // 2 then 5 cannot form 25 hours, so 5 starts a new entry and completes it.
683        type_keys(&mut editor, "25");
684        assert_eq!(editor.time, hms(5, 0, 0));
685        assert_eq!(editor.segment, TimeSegment::Minute);
686    }
687
688    #[test]
689    fn stepping_wraps_without_carry() {
690        let mut editor = editor(TimePrecision::Minute, HourCycle::H23);
691        editor.set_time(hms(23, 59, 0));
692        editor.step(1);
693        assert_eq!(editor.time, hms(0, 59, 0));
694        editor.select_segment(TimeSegment::Minute);
695        editor.step(1);
696        assert_eq!(editor.time, hms(0, 0, 0));
697        editor.step(-1);
698        assert_eq!(editor.time, hms(0, 59, 0));
699    }
700
701    #[test]
702    fn segment_movement_stops_at_the_ends() {
703        let mut editor = editor(TimePrecision::Minute, HourCycle::H23);
704        assert!(!editor.move_segment(-1));
705        assert!(editor.move_segment(1));
706        assert!(!editor.move_segment(1));
707        assert!(!editor.select_segment(TimeSegment::Second));
708        assert!(!editor.select_segment(TimeSegment::Period));
709    }
710
711    #[test]
712    fn precision_truncates_seconds() {
713        let mut editor = editor(TimePrecision::Second, HourCycle::H23);
714        editor.set_time(hms(9, 30, 15));
715        editor.select_segment(TimeSegment::Second);
716        editor.set_precision(TimePrecision::Minute);
717        assert_eq!(editor.time, hms(9, 30, 0));
718        assert_eq!(editor.segment, TimeSegment::Hour);
719    }
720
721    #[test]
722    fn clearing_resets_only_the_selected_segment() {
723        let mut editor = editor(TimePrecision::Minute, HourCycle::H23);
724        editor.set_time(hms(9, 30, 0));
725        editor.select_segment(TimeSegment::Minute);
726        assert!(editor.clear_segment());
727        assert_eq!(editor.time, hms(9, 0, 0));
728    }
729
730    #[test]
731    fn twelve_hour_labels_map_midnight_and_noon_to_twelve() {
732        let mut editor = editor(TimePrecision::Minute, HourCycle::H12);
733        for (hour, label, period) in [
734            (0, "12", "AM"),
735            (9, "09", "AM"),
736            (12, "12", "PM"),
737            (23, "11", "PM"),
738        ] {
739            editor.set_time(hms(hour, 0, 0));
740            assert_eq!(editor.label(TimeSegment::Hour), label);
741            assert_eq!(editor.label(TimeSegment::Period), period);
742        }
743    }
744
745    #[test]
746    fn twelve_hour_typing_keeps_the_period_until_it_is_typed() {
747        let mut editor = editor(TimePrecision::Minute, HourCycle::H12);
748        // "1" may start 10–12, so it waits; "2" makes 12, which is midnight in AM.
749        type_keys(&mut editor, "12");
750        assert_eq!(editor.time, hms(0, 0, 0));
751        type_keys(&mut editor, "30p");
752        assert_eq!(editor.time, hms(12, 30, 0));
753        assert_eq!(editor.segment, TimeSegment::Period);
754        editor.select_segment(TimeSegment::Hour);
755        type_keys(&mut editor, "9");
756        assert_eq!(editor.time, hms(21, 30, 0));
757        // "00" is not an hour on a 12-hour clock.
758        editor.select_segment(TimeSegment::Hour);
759        type_keys(&mut editor, "00");
760        assert_eq!(editor.segment, TimeSegment::Hour);
761    }
762
763    #[test]
764    fn twelve_hour_stepping_wraps_within_the_period() {
765        let mut editor = editor(TimePrecision::Minute, HourCycle::H12);
766        editor.set_time(hms(11, 0, 0));
767        editor.step(1);
768        assert_eq!(editor.time, hms(0, 0, 0), "11 AM steps to 12 AM");
769        editor.select_segment(TimeSegment::Period);
770        editor.step(1);
771        assert_eq!(editor.time, hms(12, 0, 0));
772        editor.step(1);
773        assert_eq!(editor.time, hms(0, 0, 0));
774        assert!(!editor.input_digit(1), "digits do not edit the period");
775    }
776}