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