Skip to main content

azul_layout/widgets/
time_picker.rs

1//! Time picker widget — two numeric up/down spinners (hour + minute) side by
2//! side with value-clamping, plus an optional AM/PM toggle for 12-hour mode.
3//!
4//! This is the spinner cousin of [`crate::widgets::number_input`]: each spinner
5//! is a small column of an up arrow (`▲`), a value display, and a down arrow
6//! (`▼`). Clicking an arrow increments/decrements the value, **clamps** it to
7//! its range (hour `0..=23` in 24-hour mode or `1..=12` in 12-hour mode, minute
8//! `0..=59`), updates the state, retexts the display node via
9//! `info.change_node_text`, and invokes the optional `on_change(state)`.
10//!
11//! The clamping/retext path mirrors `number_input.rs` (a proven pattern) and the
12//! clickable-cell + sibling navigation mirrors `segmented.rs`, so this widget is
13//! well-supported. The only deliberate behaviour note:
14//!
15//! PARTIAL — minute wrap-around does NOT roll into the hour. Per the build spec,
16//! incrementing minute past 59 (or below 0) simply clamps; it does not carry
17//! into the hour spinner. A carry would require coordinating two sibling
18//! displays from one handler, which is doable but out of scope here; clamping is
19//! the conservative, non-surprising behaviour.
20//!
21//! Key types: [`TimePicker`], [`TimePickerState`], [`TimePickerOnChange`].
22
23use azul_core::{
24    callbacks::{CoreCallback, CoreCallbackData, Update},
25    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
26    refany::{OptionRefAny, RefAny},
27};
28use azul_css::dynamic_selector::CssPropertyWithConditions;
29use azul_css::dynamic_selector::CssPropertyWithConditionsVec;
30use azul_css::{
31    props::{
32        basic::{color::ColorU, StyleFontSize},
33        layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignItems, LayoutAlignSelf, LayoutFlexGrow, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight, LayoutWidth, LayoutMarginLeft},
34        property::{CssProperty, *},
35        style::{StyleBackgroundContent, StyleBackgroundContentVec, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextAlign, StyleCursor, StyleUserSelect, StyleTextColor},
36    },
37    impl_option_inner, AzString,
38};
39
40use crate::callbacks::{Callback, CallbackInfo};
41
42// ---- classes ----
43static TIME_PICKER_CLASS: &[IdOrClass] =
44    &[Class(AzString::from_const_str("__azul-native-time-picker"))];
45static SPINNER_CLASS: &[IdOrClass] =
46    &[Class(AzString::from_const_str("__azul-native-time-picker-spinner"))];
47static DISPLAY_CLASS: &[IdOrClass] =
48    &[Class(AzString::from_const_str("__azul-native-time-picker-display"))];
49static ARROW_CLASS: &[IdOrClass] =
50    &[Class(AzString::from_const_str("__azul-native-time-picker-arrow"))];
51static SEPARATOR_CLASS: &[IdOrClass] =
52    &[Class(AzString::from_const_str("__azul-native-time-picker-separator"))];
53static AMPM_CLASS: &[IdOrClass] =
54    &[Class(AzString::from_const_str("__azul-native-time-picker-ampm"))];
55
56const UP_ARROW: AzString = AzString::from_const_str("\u{25B2}"); // ▲
57const DOWN_ARROW: AzString = AzString::from_const_str("\u{25BC}"); // ▼
58const SEPARATOR_TEXT: AzString = AzString::from_const_str(":");
59
60/// Callback type invoked when the hour, minute, or AM/PM value changes.
61pub type TimePickerOnChangeCallbackType =
62    extern "C" fn(RefAny, CallbackInfo, TimePickerState) -> Update;
63impl_widget_callback!(
64    TimePickerOnChange,
65    OptionTimePickerOnChange,
66    TimePickerOnChangeCallback,
67    TimePickerOnChangeCallbackType
68);
69
70azul_core::impl_managed_callback! {
71    wrapper:        TimePickerOnChangeCallback,
72    info_ty:        CallbackInfo,
73    return_ty:      Update,
74    default_ret:    Update::DoNothing,
75    invoker_static: TIME_PICKER_ON_CHANGE_INVOKER,
76    invoker_ty:     AzTimePickerOnChangeCallbackInvoker,
77    thunk_fn:       az_time_picker_on_change_callback_thunk,
78    setter_fn:      AzApp_setTimePickerOnChangeCallbackInvoker,
79    from_handle_fn: AzTimePickerOnChangeCallback_createFromHostHandle,
80    extra_args:     [ state: TimePickerState ],
81}
82
83/// A time picker: two clamped spinners (hour + minute) and an optional AM/PM
84/// toggle.
85#[derive(Debug, Clone, PartialEq, Eq)]
86#[repr(C)]
87pub struct TimePicker {
88    pub state: TimePickerStateWrapper,
89    /// Style for the row container.
90    pub container_style: CssPropertyWithConditionsVec,
91}
92
93/// Wraps [`TimePickerState`] together with its change callback.
94#[derive(Debug, Default, Clone, PartialEq, Eq)]
95#[repr(C)]
96pub struct TimePickerStateWrapper {
97    pub inner: TimePickerState,
98    pub on_change: OptionTimePickerOnChange,
99}
100
101/// State of a [`TimePicker`].
102#[derive(Debug, Copy, Clone, PartialEq, Eq)]
103#[repr(C)]
104pub struct TimePickerState {
105    /// The displayed hour: `0..=23` when [`Self::is_24h`], else `1..=12`.
106    pub hour: u32,
107    /// The minute, `0..=59`.
108    pub minute: u32,
109    /// PM flag — only meaningful in 12-hour mode (ignored when `is_24h`).
110    pub is_pm: bool,
111    /// `true` = 24-hour display (no AM/PM), `false` = 12-hour display + AM/PM.
112    pub is_24h: bool,
113}
114
115impl Default for TimePickerState {
116    fn default() -> Self {
117        Self {
118            hour: 0,
119            minute: 0,
120            is_pm: false,
121            is_24h: true,
122        }
123    }
124}
125
126impl TimePickerState {
127    /// Returns the hour in canonical 24-hour form (`0..=23`), accounting for the
128    /// AM/PM flag in 12-hour mode (12 AM -> 0, 12 PM -> 12).
129    #[must_use] pub const fn canonical_hour(&self) -> u32 {
130        if self.is_24h {
131            self.hour
132        } else {
133            let h12 = self.hour % 12; // 12 -> 0
134            h12 + if self.is_pm { 12 } else { 0 }
135        }
136    }
137
138    #[inline]
139    const fn hour_bounds(&self) -> (i64, i64) {
140        if self.is_24h {
141            (0, 23)
142        } else {
143            (1, 12)
144        }
145    }
146}
147
148// ---- colours ----
149const BORDER_COLOR: ColorU = ColorU { r: 206, g: 212, b: 218, a: 255 };
150const ARROW_COLOR: ColorU = ColorU { r: 73, g: 80, b: 87, a: 255 };
151const TEXT_COLOR: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
152const ACCENT_BG: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
153const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
154
155const ACCENT_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(ACCENT_BG)];
156const ACCENT_BG_VEC: StyleBackgroundContentVec =
157    StyleBackgroundContentVec::from_const_slice(ACCENT_BG_ITEMS);
158
159/// Container: a horizontal row that hugs its content.
160static CONTAINER_STYLE: &[CssPropertyWithConditions] = &[
161    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
162    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
163    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
164    CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
165    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
166    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(4))),
167    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
168        LayoutPaddingBottom::const_px(4),
169    )),
170    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
171        6,
172    ))),
173    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
174        LayoutPaddingRight::const_px(6),
175    )),
176    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
177        LayoutBorderTopWidth::const_px(1),
178    )),
179    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
180        LayoutBorderBottomWidth::const_px(1),
181    )),
182    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
183        LayoutBorderLeftWidth::const_px(1),
184    )),
185    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
186        LayoutBorderRightWidth::const_px(1),
187    )),
188    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
189        inner: BorderStyle::Solid,
190    })),
191    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
192        StyleBorderBottomStyle {
193            inner: BorderStyle::Solid,
194        },
195    )),
196    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
197        inner: BorderStyle::Solid,
198    })),
199    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
200        StyleBorderRightStyle {
201            inner: BorderStyle::Solid,
202        },
203    )),
204    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
205        inner: BORDER_COLOR,
206    })),
207    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
208        StyleBorderBottomColor { inner: BORDER_COLOR },
209    )),
210    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
211        inner: BORDER_COLOR,
212    })),
213    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
214        StyleBorderRightColor { inner: BORDER_COLOR },
215    )),
216    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
217        StyleBorderTopLeftRadius::const_px(6),
218    )),
219    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
220        StyleBorderTopRightRadius::const_px(6),
221    )),
222    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
223        StyleBorderBottomLeftRadius::const_px(6),
224    )),
225    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
226        StyleBorderBottomRightRadius::const_px(6),
227    )),
228];
229
230/// One spinner column: up arrow, value, down arrow.
231static SPINNER_STYLE: &[CssPropertyWithConditions] = &[
232    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
233    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Column)),
234    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
235    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
236    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(40))),
237];
238
239/// Up/down arrow cell.
240static ARROW_STYLE: &[CssPropertyWithConditions] = &[
241    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
242    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
243    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
244    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
245    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
246        inner: ARROW_COLOR,
247    })),
248    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(2))),
249    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
250        LayoutPaddingBottom::const_px(2),
251    )),
252];
253
254/// The value display in the middle of a spinner.
255static DISPLAY_STYLE: &[CssPropertyWithConditions] = &[
256    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(18))),
257    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
258    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
259    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
260        inner: TEXT_COLOR,
261    })),
262    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(2))),
263    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
264        LayoutPaddingBottom::const_px(2),
265    )),
266];
267
268/// The `:` separator between the hour and minute spinners.
269static SEPARATOR_STYLE: &[CssPropertyWithConditions] = &[
270    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(18))),
271    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
272    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
273        inner: TEXT_COLOR,
274    })),
275    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
276        2,
277    ))),
278    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
279        LayoutPaddingRight::const_px(2),
280    )),
281];
282
283/// The clickable AM/PM toggle (12-hour mode only).
284static AMPM_STYLE: &[CssPropertyWithConditions] = &[
285    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
286    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
287    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
288    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
289    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor { inner: WHITE })),
290    CssPropertyWithConditions::simple(CssProperty::const_background_content(ACCENT_BG_VEC)),
291    CssPropertyWithConditions::simple(CssProperty::const_margin_left(LayoutMarginLeft::const_px(8))),
292    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(4))),
293    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
294        LayoutPaddingBottom::const_px(4),
295    )),
296    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
297        8,
298    ))),
299    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
300        LayoutPaddingRight::const_px(8),
301    )),
302    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
303        StyleBorderTopLeftRadius::const_px(4),
304    )),
305    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
306        StyleBorderTopRightRadius::const_px(4),
307    )),
308    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
309        StyleBorderBottomLeftRadius::const_px(4),
310    )),
311    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
312        StyleBorderBottomRightRadius::const_px(4),
313    )),
314];
315
316impl TimePicker {
317    /// Creates a new 24-hour `TimePicker` with the given initial hour (`0..=23`)
318    /// and minute (`0..=59`), both clamped into range.
319    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
320    #[must_use] pub fn create(hour: u32, minute: u32) -> Self {
321        let mut inner = TimePickerState::default();
322        let (lo, hi) = inner.hour_bounds();
323        inner.hour = i64::from(hour).clamp(lo, hi) as u32;
324        inner.minute = i64::from(minute).clamp(0, 59) as u32;
325        Self {
326            state: TimePickerStateWrapper {
327                inner,
328                on_change: None.into(),
329            },
330            container_style: CssPropertyWithConditionsVec::from_const_slice(CONTAINER_STYLE),
331        }
332    }
333
334    /// Switches between 24-hour (no AM/PM) and 12-hour (with AM/PM) display,
335    /// re-clamping the hour into the new range.
336    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
337    pub fn set_24h(&mut self, is_24h: bool) {
338        self.state.inner.is_24h = is_24h;
339        let (lo, hi) = self.state.inner.hour_bounds();
340        self.state.inner.hour = i64::from(self.state.inner.hour).clamp(lo, hi) as u32;
341    }
342
343    /// Builder variant of [`Self::set_24h`].
344    #[must_use] pub fn with_24h(mut self, is_24h: bool) -> Self {
345        self.set_24h(is_24h);
346        self
347    }
348
349    /// Sets the AM/PM flag (only meaningful in 12-hour mode).
350    pub const fn set_pm(&mut self, is_pm: bool) {
351        self.state.inner.is_pm = is_pm;
352    }
353
354    /// Builder variant of [`Self::set_pm`].
355    #[must_use] pub const fn with_pm(mut self, is_pm: bool) -> Self {
356        self.set_pm(is_pm);
357        self
358    }
359
360    /// Sets the callback invoked when any value changes.
361    pub fn set_on_change<C: Into<TimePickerOnChangeCallback>>(&mut self, data: RefAny, callback: C) {
362        self.state.on_change = Some(TimePickerOnChange {
363            callback: callback.into(),
364            refany: data,
365        })
366        .into();
367    }
368
369    /// Builder variant of [`Self::set_on_change`].
370    #[must_use] pub fn with_on_change<C: Into<TimePickerOnChangeCallback>>(
371        mut self,
372        data: RefAny,
373        callback: C,
374    ) -> Self {
375        self.set_on_change(data, callback);
376        self
377    }
378
379    /// Replaces `self` with the default value and returns the original.
380    #[must_use] pub fn swap_with_default(&mut self) -> Self {
381        let mut s = Self::create(0, 0);
382        core::mem::swap(&mut s, self);
383        s
384    }
385
386    #[must_use] pub fn dom(self) -> Dom {
387        let inner = self.state.inner;
388        let is_24h = inner.is_24h;
389        let hour_text = AzString::from(format!("{}", inner.hour));
390        let minute_text = AzString::from(format!("{:02}", inner.minute));
391        let container_style = self.container_style.clone();
392
393        let state = RefAny::new(self.state);
394
395        let mut children = alloc::vec![
396            build_spinner(
397                hour_text,
398                state.clone(),
399                on_hour_up as usize,
400                on_hour_down as usize,
401            ),
402            Dom::create_text(SEPARATOR_TEXT)
403                .with_ids_and_classes(IdOrClassVec::from_const_slice(SEPARATOR_CLASS))
404                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(SEPARATOR_STYLE)),
405            build_spinner(
406                minute_text,
407                state.clone(),
408                on_minute_up as usize,
409                on_minute_down as usize,
410            ),
411        ];
412
413        if !is_24h {
414            let ampm_text = if inner.is_pm {
415                AzString::from_const_str("PM")
416            } else {
417                AzString::from_const_str("AM")
418            };
419            children.push(
420                Dom::create_text(ampm_text)
421                    .with_ids_and_classes(IdOrClassVec::from_const_slice(AMPM_CLASS))
422                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(AMPM_STYLE))
423                    .with_callbacks(
424                        alloc::vec![CoreCallbackData {
425                            event: azul_core::dom::EventFilter::Hover(
426                                azul_core::dom::HoverEventFilter::MouseUp,
427                            ),
428                            callback: CoreCallback {
429                                cb: on_ampm_toggle as usize,
430                                ctx: OptionRefAny::None,
431                            },
432                            refany: state,
433                        }]
434                        .into(),
435                    )
436                    .with_tab_index(TabIndex::Auto),
437            );
438        }
439
440        Dom::create_div()
441            .with_ids_and_classes(IdOrClassVec::from_const_slice(TIME_PICKER_CLASS))
442            .with_css_props(container_style)
443            .with_children(children.into())
444    }
445}
446
447impl Default for TimePicker {
448    fn default() -> Self {
449        Self::create(0, 0)
450    }
451}
452
453/// Builds one spinner column (up arrow / value display / down arrow). The up and
454/// down arrows carry the shared `state` `RefAny` and the given click handlers; the
455/// middle display is class-tagged so handlers can re-text it.
456fn build_spinner(value: AzString, state: RefAny, up_cb: usize, down_cb: usize) -> Dom {
457    use azul_core::dom::{EventFilter, HoverEventFilter};
458
459    let arrow_cell = |arrow: AzString, cb: usize, refany: RefAny| -> Dom {
460        Dom::create_text(arrow)
461            .with_ids_and_classes(IdOrClassVec::from_const_slice(ARROW_CLASS))
462            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(ARROW_STYLE))
463            .with_callbacks(
464                alloc::vec![CoreCallbackData {
465                    event: EventFilter::Hover(HoverEventFilter::MouseUp),
466                    callback: CoreCallback {
467                        cb,
468                        ctx: OptionRefAny::None,
469                    },
470                    refany,
471                }]
472                .into(),
473            )
474            .with_tab_index(TabIndex::Auto)
475    };
476
477    Dom::create_div()
478        .with_ids_and_classes(IdOrClassVec::from_const_slice(SPINNER_CLASS))
479        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(SPINNER_STYLE))
480        .with_children(
481            alloc::vec![
482                arrow_cell(UP_ARROW, up_cb, state.clone()),
483                Dom::create_text(value)
484                    .with_ids_and_classes(IdOrClassVec::from_const_slice(DISPLAY_CLASS))
485                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(DISPLAY_STYLE)),
486                arrow_cell(DOWN_ARROW, down_cb, state),
487            ]
488            .into(),
489        )
490}
491
492/// Shared spinner logic: clamps the targeted field, re-texts the display node
493/// (the middle child of the clicked arrow's parent spinner), and fires the
494/// optional `on_change`.
495#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
496fn adjust_spinner(mut data: RefAny, mut info: CallbackInfo, is_hour: bool, delta: i64) -> Update {
497    // The clicked node is an arrow; its parent is the spinner; the spinner's
498    // first child is the up arrow and the next sibling is the value display.
499    let hit = info.get_hit_node();
500    let Some(parent) = info.get_parent(hit) else {
501        return Update::DoNothing;
502    };
503    let Some(up) = info.get_first_child(parent) else {
504        return Update::DoNothing;
505    };
506    let Some(display) = info.get_next_sibling(up) else {
507        return Update::DoNothing;
508    };
509
510    let (update, display_text) = {
511        let Some(mut w) = data.downcast_mut::<TimePickerStateWrapper>() else {
512            return Update::DoNothing;
513        };
514
515        let display_text = if is_hour {
516            let (lo, hi) = w.inner.hour_bounds();
517            w.inner.hour = (i64::from(w.inner.hour) + delta).clamp(lo, hi) as u32;
518            AzString::from(format!("{}", w.inner.hour))
519        } else {
520            // PARTIAL: minute clamps; it does not wrap/carry into the hour.
521            w.inner.minute = (i64::from(w.inner.minute) + delta).clamp(0, 59) as u32;
522            AzString::from(format!("{:02}", w.inner.minute))
523        };
524
525        let inner = w.inner;
526        let w = &mut *w;
527        let update = match w.on_change.as_mut() {
528            Some(TimePickerOnChange { callback, refany }) => {
529                (callback.cb)(refany.clone(), info, inner)
530            }
531            None => Update::DoNothing,
532        };
533        (update, display_text)
534    };
535
536    info.change_node_text(display, display_text);
537    update
538}
539
540extern "C" fn on_hour_up(data: RefAny, info: CallbackInfo) -> Update {
541    adjust_spinner(data, info, true, 1)
542}
543
544extern "C" fn on_hour_down(data: RefAny, info: CallbackInfo) -> Update {
545    adjust_spinner(data, info, true, -1)
546}
547
548extern "C" fn on_minute_up(data: RefAny, info: CallbackInfo) -> Update {
549    adjust_spinner(data, info, false, 1)
550}
551
552extern "C" fn on_minute_down(data: RefAny, info: CallbackInfo) -> Update {
553    adjust_spinner(data, info, false, -1)
554}
555
556/// Toggles the AM/PM flag and re-texts the clicked toggle node.
557extern "C" fn on_ampm_toggle(mut data: RefAny, mut info: CallbackInfo) -> Update {
558    let hit = info.get_hit_node();
559
560    let (update, text) = {
561        let Some(mut w) = data.downcast_mut::<TimePickerStateWrapper>() else {
562            return Update::DoNothing;
563        };
564        w.inner.is_pm = !w.inner.is_pm;
565        let inner = w.inner;
566        let text = if inner.is_pm {
567            AzString::from_const_str("PM")
568        } else {
569            AzString::from_const_str("AM")
570        };
571        let w = &mut *w;
572        let update = match w.on_change.as_mut() {
573            Some(TimePickerOnChange { callback, refany }) => {
574                (callback.cb)(refany.clone(), info, inner)
575            }
576            None => Update::DoNothing,
577        };
578        (update, text)
579    };
580
581    info.change_node_text(hit, text);
582    update
583}
584
585impl From<TimePicker> for Dom {
586    fn from(t: TimePicker) -> Self {
587        t.dom()
588    }
589}