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

            
23
use azul_core::{
24
    callbacks::{CoreCallback, CoreCallbackData, Update},
25
    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
26
    refany::{OptionRefAny, RefAny},
27
};
28
use azul_css::dynamic_selector::CssPropertyWithConditions;
29
use azul_css::dynamic_selector::CssPropertyWithConditionsVec;
30
use 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

            
40
use crate::callbacks::{Callback, CallbackInfo};
41

            
42
// ---- classes ----
43
static TIME_PICKER_CLASS: &[IdOrClass] =
44
    &[Class(AzString::from_const_str("__azul-native-time-picker"))];
45
static SPINNER_CLASS: &[IdOrClass] =
46
    &[Class(AzString::from_const_str("__azul-native-time-picker-spinner"))];
47
static DISPLAY_CLASS: &[IdOrClass] =
48
    &[Class(AzString::from_const_str("__azul-native-time-picker-display"))];
49
static ARROW_CLASS: &[IdOrClass] =
50
    &[Class(AzString::from_const_str("__azul-native-time-picker-arrow"))];
51
static SEPARATOR_CLASS: &[IdOrClass] =
52
    &[Class(AzString::from_const_str("__azul-native-time-picker-separator"))];
53
static AMPM_CLASS: &[IdOrClass] =
54
    &[Class(AzString::from_const_str("__azul-native-time-picker-ampm"))];
55

            
56
const UP_ARROW: AzString = AzString::from_const_str("\u{25B2}"); // ▲
57
const DOWN_ARROW: AzString = AzString::from_const_str("\u{25BC}"); // ▼
58
const SEPARATOR_TEXT: AzString = AzString::from_const_str(":");
59

            
60
/// Callback type invoked when the hour, minute, or AM/PM value changes.
61
pub type TimePickerOnChangeCallbackType =
62
    extern "C" fn(RefAny, CallbackInfo, TimePickerState) -> Update;
63
impl_widget_callback!(
64
    TimePickerOnChange,
65
    OptionTimePickerOnChange,
66
    TimePickerOnChangeCallback,
67
    TimePickerOnChangeCallbackType
68
);
69

            
70
azul_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)]
87
pub 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)]
96
pub 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)]
104
pub 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

            
115
impl Default for TimePickerState {
116
622
    fn default() -> Self {
117
622
        Self {
118
622
            hour: 0,
119
622
            minute: 0,
120
622
            is_pm: false,
121
622
            is_24h: true,
122
622
        }
123
622
    }
124
}
125

            
126
impl 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
164
    #[must_use] pub const fn canonical_hour(&self) -> u32 {
130
164
        if self.is_24h {
131
72
            self.hour
132
        } else {
133
92
            let h12 = self.hour % 12; // 12 -> 0
134
92
            h12 + if self.is_pm { 12 } else { 0 }
135
        }
136
164
    }
137

            
138
    #[inline]
139
1166
    const fn hour_bounds(&self) -> (i64, i64) {
140
1166
        if self.is_24h {
141
1010
            (0, 23)
142
        } else {
143
156
            (1, 12)
144
        }
145
1166
    }
146
}
147

            
148
// ---- colours ----
149
const BORDER_COLOR: ColorU = ColorU { r: 206, g: 212, b: 218, a: 255 };
150
const ARROW_COLOR: ColorU = ColorU { r: 73, g: 80, b: 87, a: 255 };
151
const TEXT_COLOR: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
152
const ACCENT_BG: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
153
const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
154

            
155
const ACCENT_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(ACCENT_BG)];
156
const ACCENT_BG_VEC: StyleBackgroundContentVec =
157
    StyleBackgroundContentVec::from_const_slice(ACCENT_BG_ITEMS);
158

            
159
/// Container: a horizontal row that hugs its content.
160
static 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.
231
static 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.
240
static 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.
255
static 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.
269
static 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).
284
static 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

            
316
impl 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
603
    #[must_use] pub fn create(hour: u32, minute: u32) -> Self {
321
603
        let mut inner = TimePickerState::default();
322
603
        let (lo, hi) = inner.hour_bounds();
323
603
        inner.hour = i64::from(hour).clamp(lo, hi) as u32;
324
603
        inner.minute = i64::from(minute).clamp(0, 59) as u32;
325
603
        Self {
326
603
            state: TimePickerStateWrapper {
327
603
                inner,
328
603
                on_change: None.into(),
329
603
            },
330
603
            container_style: CssPropertyWithConditionsVec::from_const_slice(CONTAINER_STYLE),
331
603
        }
332
603
    }
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
163
    pub fn set_24h(&mut self, is_24h: bool) {
338
163
        self.state.inner.is_24h = is_24h;
339
163
        let (lo, hi) = self.state.inner.hour_bounds();
340
163
        self.state.inner.hour = i64::from(self.state.inner.hour).clamp(lo, hi) as u32;
341
163
    }
342

            
343
    /// Builder variant of [`Self::set_24h`].
344
62
    #[must_use] pub fn with_24h(mut self, is_24h: bool) -> Self {
345
62
        self.set_24h(is_24h);
346
62
        self
347
62
    }
348

            
349
    /// Sets the AM/PM flag (only meaningful in 12-hour mode).
350
25
    pub const fn set_pm(&mut self, is_pm: bool) {
351
25
        self.state.inner.is_pm = is_pm;
352
25
    }
353

            
354
    /// Builder variant of [`Self::set_pm`].
355
14
    #[must_use] pub const fn with_pm(mut self, is_pm: bool) -> Self {
356
14
        self.set_pm(is_pm);
357
14
        self
358
14
    }
359

            
360
    /// Sets the callback invoked when any value changes.
361
17
    pub fn set_on_change<C: Into<TimePickerOnChangeCallback>>(&mut self, data: RefAny, callback: C) {
362
17
        self.state.on_change = Some(TimePickerOnChange {
363
17
            callback: callback.into(),
364
17
            refany: data,
365
17
        })
366
17
        .into();
367
17
    }
368

            
369
    /// Builder variant of [`Self::set_on_change`].
370
12
    #[must_use] pub fn with_on_change<C: Into<TimePickerOnChangeCallback>>(
371
12
        mut self,
372
12
        data: RefAny,
373
12
        callback: C,
374
12
    ) -> Self {
375
12
        self.set_on_change(data, callback);
376
12
        self
377
12
    }
378

            
379
    /// Replaces `self` with the default value and returns the original.
380
5
    #[must_use] pub fn swap_with_default(&mut self) -> Self {
381
5
        let mut s = Self::create(0, 0);
382
5
        core::mem::swap(&mut s, self);
383
5
        s
384
5
    }
385

            
386
262
    #[must_use] pub fn dom(self) -> Dom {
387
262
        let inner = self.state.inner;
388
262
        let is_24h = inner.is_24h;
389
262
        let hour_text = AzString::from(format!("{}", inner.hour));
390
262
        let minute_text = AzString::from(format!("{:02}", inner.minute));
391
262
        let container_style = self.container_style.clone();
392

            
393
262
        let state = RefAny::new(self.state);
394

            
395
262
        let mut children = alloc::vec![
396
262
            build_spinner(
397
262
                hour_text,
398
262
                state.clone(),
399
262
                on_hour_up as usize,
400
262
                on_hour_down as usize,
401
            ),
402
262
            Dom::create_p_with_text(SEPARATOR_TEXT)
403
262
                .with_ids_and_classes(IdOrClassVec::from_const_slice(SEPARATOR_CLASS))
404
262
                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(SEPARATOR_STYLE)),
405
262
            build_spinner(
406
262
                minute_text,
407
262
                state.clone(),
408
262
                on_minute_up as usize,
409
262
                on_minute_down as usize,
410
            ),
411
        ];
412

            
413
262
        if !is_24h {
414
40
            let ampm_text = if inner.is_pm {
415
4
                AzString::from_const_str("PM")
416
            } else {
417
36
                AzString::from_const_str("AM")
418
            };
419
40
            children.push(
420
40
                Dom::create_p_with_text(ampm_text)
421
40
                    .with_ids_and_classes(IdOrClassVec::from_const_slice(AMPM_CLASS))
422
40
                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(AMPM_STYLE))
423
40
                    .with_callbacks(
424
40
                        alloc::vec![CoreCallbackData {
425
40
                            event: azul_core::dom::EventFilter::Hover(
426
40
                                azul_core::dom::HoverEventFilter::MouseUp,
427
40
                            ),
428
40
                            callback: CoreCallback {
429
40
                                cb: on_ampm_toggle as usize,
430
40
                                ctx: OptionRefAny::None,
431
40
                            },
432
40
                            refany: state,
433
40
                        }]
434
40
                        .into(),
435
                    )
436
40
                    .with_tab_index(TabIndex::Auto),
437
            );
438
222
        }
439

            
440
262
        Dom::create_div()
441
262
            .with_ids_and_classes(IdOrClassVec::from_const_slice(TIME_PICKER_CLASS))
442
262
            .with_css_props(container_style)
443
262
            .with_children(children.into())
444
262
    }
445
}
446

            
447
impl Default for TimePicker {
448
8
    fn default() -> Self {
449
8
        Self::create(0, 0)
450
8
    }
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.
456
547
fn build_spinner(value: AzString, state: RefAny, up_cb: usize, down_cb: usize) -> Dom {
457
    use azul_core::dom::{EventFilter, HoverEventFilter};
458

            
459
1094
    let arrow_cell = |arrow: AzString, cb: usize, refany: RefAny| -> Dom {
460
1094
        Dom::create_p_with_text(arrow)
461
1094
            .with_ids_and_classes(IdOrClassVec::from_const_slice(ARROW_CLASS))
462
1094
            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(ARROW_STYLE))
463
1094
            .with_callbacks(
464
1094
                alloc::vec![CoreCallbackData {
465
1094
                    event: EventFilter::Hover(HoverEventFilter::MouseUp),
466
1094
                    callback: CoreCallback {
467
1094
                        cb,
468
1094
                        ctx: OptionRefAny::None,
469
1094
                    },
470
1094
                    refany,
471
1094
                }]
472
1094
                .into(),
473
            )
474
1094
            .with_tab_index(TabIndex::Auto)
475
1094
    };
476

            
477
547
    Dom::create_div()
478
547
        .with_ids_and_classes(IdOrClassVec::from_const_slice(SPINNER_CLASS))
479
547
        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(SPINNER_STYLE))
480
547
        .with_children(
481
547
            alloc::vec![
482
547
                arrow_cell(UP_ARROW, up_cb, state.clone()),
483
547
                Dom::create_p_with_text(value)
484
547
                    .with_ids_and_classes(IdOrClassVec::from_const_slice(DISPLAY_CLASS))
485
547
                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(DISPLAY_STYLE)),
486
547
                arrow_cell(DOWN_ARROW, down_cb, state),
487
            ]
488
547
            .into(),
489
        )
490
547
}
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
496
473
fn adjust_spinner(mut data: RefAny, mut info: CallbackInfo, is_hour: bool, delta: i64) -> Update {
497
    // The clicked node is an arrow `<p>`; its parent is the spinner; the
498
    // spinner's first child is the up arrow and the next sibling is the value
499
    // display `<p>`, whose only child is the re-textable bare text node.
500
473
    let hit = info.get_hit_node();
501
473
    let Some(parent) = info.get_parent(hit) else {
502
4
        return Update::DoNothing;
503
    };
504
469
    let Some(up) = info.get_first_child(parent) else {
505
        return Update::DoNothing;
506
    };
507
469
    let Some(display_box) = info.get_next_sibling(up) else {
508
        return Update::DoNothing;
509
    };
510
469
    let Some(display) = info.get_first_child(display_box) else {
511
        return Update::DoNothing;
512
    };
513

            
514
466
    let (update, display_text) = {
515
469
        let Some(mut w) = data.downcast_mut::<TimePickerStateWrapper>() else {
516
3
            return Update::DoNothing;
517
        };
518

            
519
466
        let display_text = if is_hour {
520
252
            let (lo, hi) = w.inner.hour_bounds();
521
252
            w.inner.hour = (i64::from(w.inner.hour) + delta).clamp(lo, hi) as u32;
522
252
            AzString::from(format!("{}", w.inner.hour))
523
        } else {
524
            // PARTIAL: minute clamps; it does not wrap/carry into the hour.
525
214
            w.inner.minute = (i64::from(w.inner.minute) + delta).clamp(0, 59) as u32;
526
214
            AzString::from(format!("{:02}", w.inner.minute))
527
        };
528

            
529
466
        let inner = w.inner;
530
466
        let w = &mut *w;
531
466
        let update = match w.on_change.as_mut() {
532
10
            Some(TimePickerOnChange { callback, refany }) => {
533
10
                (callback.cb)(refany.clone(), info, inner)
534
            }
535
456
            None => Update::DoNothing,
536
        };
537
466
        (update, display_text)
538
    };
539

            
540
466
    info.change_node_text(display, display_text);
541
466
    update
542
473
}
543

            
544
237
extern "C" fn on_hour_up(data: RefAny, info: CallbackInfo) -> Update {
545
237
    adjust_spinner(data, info, true, 1)
546
237
}
547

            
548
6
extern "C" fn on_hour_down(data: RefAny, info: CallbackInfo) -> Update {
549
6
    adjust_spinner(data, info, true, -1)
550
6
}
551

            
552
5
extern "C" fn on_minute_up(data: RefAny, info: CallbackInfo) -> Update {
553
5
    adjust_spinner(data, info, false, 1)
554
5
}
555

            
556
203
extern "C" fn on_minute_down(data: RefAny, info: CallbackInfo) -> Update {
557
203
    adjust_spinner(data, info, false, -1)
558
203
}
559

            
560
/// Toggles the AM/PM flag and re-texts the clicked toggle node. The hit node is
561
/// the toggle's `<p>` box; the text it wraps is what gets re-texted.
562
26
extern "C" fn on_ampm_toggle(mut data: RefAny, mut info: CallbackInfo) -> Update {
563
26
    let hit = info.get_hit_node();
564
26
    let Some(text_node) = info.get_first_child(hit) else {
565
2
        return Update::DoNothing;
566
    };
567

            
568
22
    let (update, text) = {
569
24
        let Some(mut w) = data.downcast_mut::<TimePickerStateWrapper>() else {
570
2
            return Update::DoNothing;
571
        };
572
22
        w.inner.is_pm = !w.inner.is_pm;
573
22
        let inner = w.inner;
574
22
        let text = if inner.is_pm {
575
19
            AzString::from_const_str("PM")
576
        } else {
577
3
            AzString::from_const_str("AM")
578
        };
579
22
        let w = &mut *w;
580
22
        let update = match w.on_change.as_mut() {
581
1
            Some(TimePickerOnChange { callback, refany }) => {
582
1
                (callback.cb)(refany.clone(), info, inner)
583
            }
584
21
            None => Update::DoNothing,
585
        };
586
22
        (update, text)
587
    };
588

            
589
22
    info.change_node_text(text_node, text);
590
22
    update
591
26
}
592

            
593
impl From<TimePicker> for Dom {
594
1
    fn from(t: TimePicker) -> Self {
595
1
        t.dom()
596
1
    }
597
}
598

            
599
#[cfg(test)]
600
mod autotest_generated {
601
    use std::{
602
        collections::{BTreeMap, HashMap},
603
        sync::{Arc, Mutex},
604
    };
605

            
606
    use azul_core::{
607
        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
608
        geom::{LogicalRect, OptionLogicalPosition},
609
        gl::OptionGlContextPtr,
610
        hit_test::ScrollPosition,
611
        resources::RendererResources,
612
        styled_dom::{NodeHierarchyItemId, StyledDom},
613
        window::{MonitorVec, RawWindowHandle},
614
    };
615
    use rust_fontconfig::FcFontCache;
616

            
617
    use super::*;
618
    #[cfg(feature = "icu")]
619
    use crate::icu::IcuLocalizerHandle;
620
    use crate::{
621
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
622
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
623
        window::{DomLayoutResult, LayoutWindow},
624
        window_state::FullWindowState,
625
    };
626

            
627
    // ==================================================================
628
    // Const-evaluated extremes
629
    //
630
    // `canonical_hour` and `hour_bounds` are `const fn`, so evaluating them on
631
    // states holding the integer extremes in a `const` item makes the compiler
632
    // itself prove there is no overflow / const-eval panic on those inputs —
633
    // a stronger statement than any runtime assertion.
634
    // ==================================================================
635

            
636
    const EXTREME_24H: TimePickerState = TimePickerState {
637
        hour: u32::MAX,
638
        minute: u32::MAX,
639
        is_pm: true,
640
        is_24h: true,
641
    };
642
    const EXTREME_12H: TimePickerState = TimePickerState {
643
        hour: u32::MAX,
644
        minute: u32::MAX,
645
        is_pm: true,
646
        is_24h: false,
647
    };
648

            
649
    const _CANONICAL_AT_MAX_24H: u32 = EXTREME_24H.canonical_hour();
650
    const _CANONICAL_AT_MAX_12H: u32 = EXTREME_12H.canonical_hour();
651
    const _BOUNDS_AT_MAX_24H: (i64, i64) = EXTREME_24H.hour_bounds();
652
    const _BOUNDS_AT_MAX_12H: (i64, i64) = EXTREME_12H.hour_bounds();
653

            
654
    // ==================================================================
655
    // Flattened node layout
656
    //
657
    // `convert_dom_into_compact_dom` walks the tree in pre-order, and a time
658
    // picker is a fixed-shape widget. Every label is a `<p>` box wrapping one
659
    // bare text node (the widget label convention), so each cell costs two
660
    // flattened nodes:
661
    //
662
    //     container
663
    //       hour spinner   → [▲ <p> → text, display <p> → text, ▼ <p> → text]
664
    //       ":" <p> → text
665
    //       minute spinner → [▲ <p> → text, display <p> → text, ▼ <p> → text]
666
    //       AM/PM <p> → text   (12-hour mode only)
667
    //
668
    // so the flattened indices are constant. `flattened_layout_is_the_fixed
669
    // _seventeen_or_nineteen_nodes` pins them against the real hierarchy, so the
670
    // click tests below cannot silently drift onto the wrong node.
671
    // ==================================================================
672

            
673
    const N_CONTAINER: usize = 0;
674
    const N_HOUR_SPINNER: usize = 1;
675
    const N_HOUR_UP: usize = 2;
676
    const N_HOUR_DISPLAY: usize = 4;
677
    const N_HOUR_DOWN: usize = 6;
678
    const N_SEPARATOR: usize = 8;
679
    const N_MINUTE_SPINNER: usize = 10;
680
    const N_MINUTE_UP: usize = 11;
681
    const N_MINUTE_DISPLAY: usize = 13;
682
    /// The bare text leaf inside the minute display's `<p>` — the node
683
    /// `adjust_spinner` retexts (the label convention moved the text one
684
    /// level under the styled wrapper).
685
    const N_MINUTE_TEXT: usize = 14;
686
    const N_MINUTE_DOWN: usize = 15;
687
    const N_AMPM: usize = 17;
688

            
689
    /// The bare text node a label `<p>` wraps — pre-order puts it right after its
690
    /// box. This is what `change_node_text` has to target.
691
    const fn text_leaf(label_box: usize) -> usize {
692
        label_box + 1
693
    }
694

            
695
    /// Total flattened node count, 24-hour and 12-hour.
696
    const N_NODES_24H: usize = 17;
697
    const N_NODES_12H: usize = 19;
698

            
699
    // The class names are part of the widget's public surface: user stylesheets
700
    // select on them, so a rename is a breaking change and is spelled out here
701
    // rather than read back out of the statics under test.
702
    const CLASS_CONTAINER: &str = "__azul-native-time-picker";
703
    const CLASS_SPINNER: &str = "__azul-native-time-picker-spinner";
704
    const CLASS_DISPLAY: &str = "__azul-native-time-picker-display";
705
    const CLASS_ARROW: &str = "__azul-native-time-picker-arrow";
706
    const CLASS_SEPARATOR: &str = "__azul-native-time-picker-separator";
707
    const CLASS_AMPM: &str = "__azul-native-time-picker-ampm";
708

            
709
    const UP_GLYPH: &str = "\u{25B2}";
710
    const DOWN_GLYPH: &str = "\u{25BC}";
711

            
712
    // ==================================================================
713
    // Harness
714
    // ==================================================================
715

            
716
    /// A `DomNodeId` in the root DOM pointing at flattened node `idx`.
717
    fn node(idx: usize) -> DomNodeId {
718
        DomNodeId {
719
            dom: DomId::ROOT_ID,
720
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
721
        }
722
    }
723

            
724
    /// A `DomNodeId` whose node component is `None` — the "no concrete node was
725
    /// hit" case that every hierarchy query must decline rather than index.
726
    fn node_none() -> DomNodeId {
727
        DomNodeId {
728
            dom: DomId::ROOT_ID,
729
            node: NodeHierarchyItemId::NONE,
730
        }
731
    }
732

            
733
    /// A `DomLayoutResult` carrying only a `styled_dom`: the time-picker handlers
734
    /// reach exactly four `CallbackInfo` queries (`get_hit_node`, `get_parent`,
735
    /// `get_first_child`, `get_next_sibling`), all of which read the node
736
    /// hierarchy only — no real layout (and no font) is needed.
737
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
738
        DomLayoutResult {
739
            styled_dom,
740
            layout_tree: LayoutTree {
741
                nodes: Vec::new(),
742
                warm: Vec::new(),
743
                cold: Vec::new(),
744
                root: 0,
745
                dom_to_layout: BTreeMap::new(),
746
                children_arena: Vec::new(),
747
                children_offsets: Vec::new(),
748
                subtree_needs_intrinsic: Vec::new(),
749
            },
750
            calculated_positions: Vec::new(),
751
            viewport: LogicalRect::zero(),
752
            display_list: Arc::new(DisplayList::default()),
753
            scroll_ids: HashMap::new(),
754
            scroll_id_to_node_id: HashMap::new(),
755
        }
756
    }
757

            
758
    /// Runs `f` with a `CallbackInfo` whose window holds `styled_dom` as the root
759
    /// DOM and whose hit node is `hit`. Returns `f`'s value plus every change the
760
    /// callback pushed onto the transaction log.
761
    fn with_info<R>(
762
        styled_dom: StyledDom,
763
        hit: DomNodeId,
764
        f: impl FnOnce(&mut CallbackInfo) -> R,
765
    ) -> (R, Vec<CallbackChange>) {
766
        let mut layout_window =
767
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
768
        layout_window
769
            .layout_results
770
            .insert(DomId::ROOT_ID, layout_result(styled_dom));
771

            
772
        let renderer_resources = RendererResources::default();
773
        let previous_window_state: Option<FullWindowState> = None;
774
        let current_window_state = FullWindowState::default();
775
        let gl_context = OptionGlContextPtr::None;
776
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
777
            BTreeMap::new();
778
        let window_handle = RawWindowHandle::Unsupported;
779
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
780

            
781
        let ref_data = CallbackInfoRefData {
782
            layout_window: &layout_window,
783
            renderer_resources: &renderer_resources,
784
            previous_window_state: &previous_window_state,
785
            current_window_state: &current_window_state,
786
            gl_context: &gl_context,
787
            current_scroll_manager: &scroll_states,
788
            current_window_handle: &window_handle,
789
            system_callbacks: &system_callbacks,
790
            system_style: Arc::new(azul_css::system::SystemStyle::default()),
791
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
792
            #[cfg(feature = "icu")]
793
            icu_localizer: IcuLocalizerHandle::default(),
794
            ctx: OptionRefAny::None,
795
        };
796

            
797
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
798

            
799
        let mut info = CallbackInfo::new(
800
            &ref_data,
801
            &changes,
802
            hit,
803
            OptionLogicalPosition::None,
804
            OptionLogicalPosition::None,
805
        );
806

            
807
        let r = f(&mut info);
808
        let pushed = info.take_changes();
809
        (r, pushed)
810
    }
811

            
812
    // ------------------------------------------------------------------
813
    // Tree probes
814
    // ------------------------------------------------------------------
815

            
816
    /// The text a node renders, or `None` for a non-text node.
817
    fn text_of(dom: &Dom) -> Option<String> {
818
        // P-wrap transparent (the label convention): a styled `<p>` wraps the
819
        // bare text leaf, so read through one wrapper level when present.
820
        dom.root.get_node_type().format().or_else(|| {
821
            let c = dom.children.as_ref();
822
            if c.len() == 1 {
823
                c[0].root.get_node_type().format()
824
            } else {
825
                None
826
            }
827
        })
828
    }
829

            
830
    fn classes(dom: &Dom) -> Vec<String> {
831
        dom.root
832
            .get_ids_and_classes()
833
            .as_ref()
834
            .iter()
835
            .filter_map(|c| match c {
836
                Class(s) => Some(s.as_str().to_string()),
837
                IdOrClass::Id(_) => None,
838
            })
839
            .collect()
840
    }
841

            
842
    /// The classes of a *flattened* node.
843
    fn flat_classes(sd: &StyledDom, idx: usize) -> Vec<String> {
844
        sd.node_data.as_ref()[idx]
845
            .get_ids_and_classes()
846
            .as_ref()
847
            .iter()
848
            .filter_map(|c| match c {
849
                Class(s) => Some(s.as_str().to_string()),
850
                IdOrClass::Id(_) => None,
851
            })
852
            .collect()
853
    }
854

            
855
    /// The text of a *flattened* node.
856
    /// The text a flattened node renders, looking through the `<p>` block
857
    /// wrapper the label convention mandates (`p > text`).
858
    fn flat_text(sd: &StyledDom, idx: usize) -> Option<String> {
859
        let data = sd.node_data.as_ref();
860
        match data[idx].get_node_type() {
861
            NodeType::P => data.get(text_leaf(idx))?.get_node_type().format(),
862
            other => other.format(),
863
        }
864
    }
865

            
866
    /// The recursive `1-per-descendant` total of a `Dom`'s children — what
867
    /// `estimated_total_children` caches. An under-report makes the flatten
868
    /// under-allocate its arenas and panic on an out-of-bounds write.
869
    fn descendants(dom: &Dom) -> usize {
870
        dom.children
871
            .as_ref()
872
            .iter()
873
            .map(|c| 1 + descendants(c))
874
            .sum()
875
    }
876

            
877
    /// The hour column, the separator and the minute column of a rendered picker.
878
    fn columns(dom: &Dom) -> (&Dom, &Dom, &Dom) {
879
        let c = dom.children.as_ref();
880
        assert!(
881
            c.len() == 3 || c.len() == 4,
882
            "a time picker renders hour + ':' + minute (+ AM/PM), got {} children",
883
            c.len(),
884
        );
885
        (&c[0], &c[1], &c[2])
886
    }
887

            
888
    /// The `(hour, minute)` strings a rendered picker displays.
889
    fn displayed(dom: &Dom) -> (String, String) {
890
        let (hour, _, minute) = columns(dom);
891
        (
892
            text_of(&hour.children.as_ref()[1]).expect("the hour display is not a text node"),
893
            text_of(&minute.children.as_ref()[1]).expect("the minute display is not a text node"),
894
        )
895
    }
896

            
897
    /// The AM/PM label, or `None` when the widget is in 24-hour mode.
898
    fn ampm_label(dom: &Dom) -> Option<String> {
899
        dom.children.as_ref().get(3).and_then(text_of)
900
    }
901

            
902
    /// Every `RefAny` in the flattened DOM that carries the widget's own state.
903
    fn state_payloads(sd: &StyledDom) -> Vec<RefAny> {
904
        let mut out = Vec::new();
905
        for nd in sd.node_data.as_ref() {
906
            for cb in nd.callbacks.as_ref() {
907
                let carries = {
908
                    let mut r = cb.refany.clone();
909
                    let carries = r.downcast_ref::<TimePickerStateWrapper>().is_some();
910
                    carries
911
                };
912
                if carries {
913
                    out.push(cb.refany.clone());
914
                }
915
            }
916
        }
917
        out
918
    }
919

            
920
    /// The single shared state `RefAny` the widget baked into its own handlers.
921
    fn shared_state(sd: &StyledDom) -> RefAny {
922
        state_payloads(sd)
923
            .into_iter()
924
            .next()
925
            .expect("the rendered time picker carries no TimePickerStateWrapper")
926
    }
927

            
928
    /// `(flattened node id, payload)` of the node wired to `handler`.
929
    fn wired_to(sd: &StyledDom, handler: usize) -> (DomNodeId, RefAny) {
930
        for (i, nd) in sd.node_data.as_ref().iter().enumerate() {
931
            for cb in nd.callbacks.as_ref() {
932
                if cb.callback.cb == handler {
933
                    return (node(i), cb.refany.clone());
934
                }
935
            }
936
        }
937
        panic!("the rendered time picker has nothing wired to that handler");
938
    }
939

            
940
    fn read_state(shared: &RefAny) -> TimePickerState {
941
        let mut s = shared.clone();
942
        let w = s
943
            .downcast_ref::<TimePickerStateWrapper>()
944
            .expect("the widget state changed type");
945
        w.inner
946
    }
947

            
948
    /// Renders a picker and hands back its flattened DOM plus the very shared
949
    /// state its own handlers were wired against — nothing is re-created by
950
    /// hand, so a mismatch between `dom()` and the handlers cannot hide here.
951
    fn laid_out(picker: TimePicker) -> (StyledDom, RefAny) {
952
        let styled = StyledDom::create_from_dom(picker.dom());
953
        let shared = shared_state(&styled);
954
        (styled, shared)
955
    }
956

            
957
    /// A hand-built shared state, for driving the handlers against values the
958
    /// public constructors clamp away.
959
    fn wrapper(inner: TimePickerState) -> RefAny {
960
        RefAny::new(TimePickerStateWrapper {
961
            inner,
962
            on_change: None.into(),
963
        })
964
    }
965

            
966
    /// One "mouse-up on `hit`" delivered to `handler`.
967
    fn press(
968
        styled_dom: StyledDom,
969
        payload: &RefAny,
970
        hit: DomNodeId,
971
        handler: extern "C" fn(RefAny, CallbackInfo) -> Update,
972
    ) -> (Update, Vec<CallbackChange>) {
973
        with_info(styled_dom, hit, |info| handler(payload.clone(), *info))
974
    }
975

            
976
    /// `times` presses of `handler` against `payload`, all delivered on `hit`.
977
    fn press_n(
978
        styled_dom: StyledDom,
979
        payload: &RefAny,
980
        hit: DomNodeId,
981
        handler: extern "C" fn(RefAny, CallbackInfo) -> Update,
982
        times: usize,
983
    ) -> (Update, Vec<CallbackChange>) {
984
        with_info(styled_dom, hit, |info| {
985
            let mut last = Update::DoNothing;
986
            for _ in 0..times {
987
                last = handler(payload.clone(), *info);
988
            }
989
            last
990
        })
991
    }
992

            
993
    /// The `(target, text)` of every text retext pushed onto the transaction log.
994
    fn pushed_texts(changes: &[CallbackChange]) -> Vec<(DomNodeId, String)> {
995
        changes
996
            .iter()
997
            .filter_map(|c| match c {
998
                CallbackChange::ChangeNodeText { node_id, text } => {
999
                    Some((*node_id, text.as_str().to_string()))
                }
                _ => None,
            })
            .collect()
    }
    /// The single retext a spinner/toggle press must push, asserting there is
    /// exactly one and that it is the *only* change of any kind.
    fn only_retext(changes: &[CallbackChange]) -> (DomNodeId, String) {
        let texts = pushed_texts(changes);
        assert_eq!(
            texts.len(),
            1,
            "expected exactly one retext, got {} change(s) total",
            changes.len(),
        );
        assert_eq!(
            changes.len(),
            1,
            "the press pushed {} change(s) beyond its retext",
            changes.len() - 1,
        );
        texts.into_iter().next().unwrap()
    }
    // ------------------------------------------------------------------
    // Style probes
    // ------------------------------------------------------------------
    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
        v.as_ref().iter().map(|p| p.property.clone()).collect()
    }
    // ------------------------------------------------------------------
    // User callbacks
    // ------------------------------------------------------------------
    /// A payload the change callback writes into. It arrives as the `data: RefAny`
    /// argument — a *shared* clone of what the test still holds — so the test can
    /// read back exactly what the widget reported, without any global state.
    #[derive(Debug, Clone, PartialEq, Eq)]
    struct ChangeLog {
        seen: Vec<TimePickerState>,
        payload: u32,
    }
    extern "C" fn record_change(
        mut data: RefAny,
        _info: CallbackInfo,
        state: TimePickerState,
    ) -> Update {
        if let Some(mut log) = data.downcast_mut::<ChangeLog>() {
            log.seen.push(state);
        }
        Update::RefreshDom
    }
    extern "C" fn change_do_nothing(
        _data: RefAny,
        _info: CallbackInfo,
        _state: TimePickerState,
    ) -> Update {
        Update::DoNothing
    }
    extern "C" fn change_refresh_all(
        _data: RefAny,
        _info: CallbackInfo,
        _state: TimePickerState,
    ) -> Update {
        Update::RefreshDomAllWindows
    }
    /// A `Callback`-shaped (2-arg) function — the shape FFI bindings hand in,
    /// which the `From<Callback>` arm *transmutes* into the 3-arg time-picker
    /// slot. Never called.
    extern "C" fn generic_shaped(_data: RefAny, _info: CallbackInfo) -> Update {
        Update::DoNothing
    }
    fn log_refany() -> RefAny {
        RefAny::new(ChangeLog {
            seen: Vec::new(),
            payload: 0xDEAD_BEEF,
        })
    }
    fn read_log(probe: &RefAny) -> ChangeLog {
        let mut probe = probe.clone();
        let log = probe
            .downcast_ref::<ChangeLog>()
            .expect("the user payload changed type");
        log.clone()
    }
    // ==================================================================
    // TimePickerState::canonical_hour
    // ==================================================================
    #[test]
    fn canonical_hour_maps_every_twelve_hour_clock_face_to_its_canonical_hour() {
        // The whole point of the function: `12 AM -> 0` and `12 PM -> 12` are the
        // two cases a naive `hour + 12*pm` gets wrong (it would answer 12 and 24).
        for (hour, is_pm, want) in [
            (12u32, false, 0u32),
            (1, false, 1),
            (11, false, 11),
            (12, true, 12),
            (1, true, 13),
            (11, true, 23),
        ] {
            let s = TimePickerState {
                hour,
                minute: 0,
                is_pm,
                is_24h: false,
            };
            assert_eq!(
                s.canonical_hour(),
                want,
                "{hour} {} is not canonical hour {want}",
                if is_pm { "PM" } else { "AM" },
            );
        }
    }
    #[test]
    fn canonical_hour_round_trips_every_hour_of_the_day_through_the_clock_face() {
        // encode (canonical -> 12-hour face + AM/PM) then decode (canonical_hour)
        // must be the identity across the whole day, or a picker built from a
        // 24-hour timestamp would hand a different hour back to the host.
        for canonical in 0..24u32 {
            let face = if canonical % 12 == 0 { 12 } else { canonical % 12 };
            let s = TimePickerState {
                hour: face,
                minute: 0,
                is_pm: canonical >= 12,
                is_24h: false,
            };
            assert_eq!(
                s.canonical_hour(),
                canonical,
                "the 12-hour encoding of {canonical}:00 did not decode back",
            );
        }
    }
    #[test]
    fn canonical_hour_never_leaves_the_day_in_twelve_hour_mode_for_any_hour() {
        // `hour` is a public `u32` field: nothing stops a host from writing 0, 13
        // or u32::MAX into it. In 12-hour mode the `% 12` must keep the answer a
        // real hour whatever it is handed.
        for hour in [
            0u32,
            1,
            12,
            13,
            23,
            24,
            25,
            59,
            100,
            1_000_000,
            u32::MAX / 2,
            u32::MAX - 1,
            u32::MAX,
        ] {
            for is_pm in [false, true] {
                let s = TimePickerState {
                    hour,
                    minute: 0,
                    is_pm,
                    is_24h: false,
                };
                let c = s.canonical_hour();
                assert!(
                    c < 24,
                    "canonical_hour({hour}, pm={is_pm}) = {c} is not an hour of the day",
                );
            }
        }
    }
    #[test]
    fn canonical_hour_in_24h_mode_returns_the_hour_verbatim() {
        // FINDING (pinned, not weakened): the doc comment promises `0..=23`, but
        // the 24-hour arm is a bare field read with no clamp. Every constructor
        // and setter in this file clamps, so the promise holds for widget-managed
        // state — but a host that writes `state.inner.hour` directly gets its own
        // value straight back out, un-normalised.
        for hour in [0u32, 23, 24, 99, u32::MAX] {
            let s = TimePickerState {
                hour,
                minute: 0,
                is_pm: false,
                is_24h: true,
            };
            assert_eq!(
                s.canonical_hour(),
                hour,
                "the 24-hour arm normalised {hour}, which it has never done",
            );
        }
    }
    #[test]
    fn canonical_hour_ignores_the_pm_flag_in_24h_mode() {
        // A stale `is_pm` left over from a 12-hour session must not shift a
        // 24-hour reading by twelve hours.
        for hour in 0..24u32 {
            let am = TimePickerState {
                hour,
                minute: 0,
                is_pm: false,
                is_24h: true,
            };
            let pm = TimePickerState { is_pm: true, ..am };
            assert_eq!(
                am.canonical_hour(),
                pm.canonical_hour(),
                "the PM flag leaked into the 24-hour reading of hour {hour}",
            );
        }
    }
    #[test]
    fn canonical_hour_does_not_depend_on_the_minute() {
        for minute in [0u32, 30, 59, 60, u32::MAX] {
            for is_24h in [false, true] {
                let s = TimePickerState {
                    hour: 7,
                    minute,
                    is_pm: true,
                    is_24h,
                };
                let baseline = TimePickerState { minute: 0, ..s };
                assert_eq!(
                    s.canonical_hour(),
                    baseline.canonical_hour(),
                    "minute {minute} changed the hour reading (is_24h={is_24h})",
                );
            }
        }
    }
    #[test]
    fn canonical_hour_of_a_default_state_is_midnight() {
        assert_eq!(TimePickerState::default().canonical_hour(), 0);
        assert_eq!(TimePicker::default().state.inner.canonical_hour(), 0);
        assert_eq!(TimePickerStateWrapper::default().inner.canonical_hour(), 0);
    }
    #[test]
    fn canonical_hour_is_total_at_the_u32_extremes() {
        // Const-evaluated above too; this pins the *values* the extremes produce.
        assert_eq!(EXTREME_24H.canonical_hour(), u32::MAX);
        // 4294967295 % 12 == 3, so the 12-hour arm answers 3 PM.
        assert_eq!(EXTREME_12H.canonical_hour(), 15);
    }
    // ==================================================================
    // TimePickerState::hour_bounds
    // ==================================================================
    #[test]
    fn hour_bounds_are_the_two_documented_bands() {
        let mut s = TimePickerState {
            is_24h: true,
            ..Default::default()
        };
        assert_eq!(s.hour_bounds(), (0, 23), "the 24-hour band is wrong");
        s.is_24h = false;
        assert_eq!(s.hour_bounds(), (1, 12), "the 12-hour band is wrong");
    }
    #[test]
    fn hour_bounds_depend_on_nothing_but_the_mode() {
        // If the band ever started depending on the current hour, clamping would
        // become order-dependent and `set_24h` would stop being idempotent.
        for hour in [0u32, 1, 12, 13, 23, u32::MAX] {
            for minute in [0u32, 59, u32::MAX] {
                for is_pm in [false, true] {
                    for is_24h in [false, true] {
                        let s = TimePickerState {
                            hour,
                            minute,
                            is_pm,
                            is_24h,
                        };
                        let want = if is_24h { (0, 23) } else { (1, 12) };
                        assert_eq!(
                            s.hour_bounds(),
                            want,
                            "hour_bounds drifted for {hour}:{minute} pm={is_pm} 24h={is_24h}",
                        );
                    }
                }
            }
        }
    }
    #[test]
    fn hour_bounds_are_ordered_and_fit_in_a_u32() {
        // The bounds are `i64` only so the clamp arithmetic cannot overflow; the
        // cast back to `u32` afterwards is unchecked, so both ends must be
        // non-negative and small.
        for is_24h in [false, true] {
            let s = TimePickerState {
                is_24h,
                ..TimePickerState::default()
            };
            let (lo, hi) = s.hour_bounds();
            assert!(lo <= hi, "hour_bounds({is_24h}) = ({lo}, {hi}) is inverted");
            assert!(lo >= 0, "a negative low bound would cast to a huge u32");
            assert!(hi <= i64::from(u32::MAX), "the high bound does not fit a u32");
            assert!(hi < 24, "the high bound is not an hour of the day");
        }
    }
    #[test]
    fn hour_bounds_agree_with_what_the_constructors_actually_clamp_to() {
        // The band and the clamp are written twice (create / set_24h); a
        // divergence would leave a widget displaying an out-of-band hour.
        let (lo24, hi24) = TimePickerState {
            is_24h: true,
            ..TimePickerState::default()
        }
        .hour_bounds();
        assert_eq!(u32::try_from(lo24).unwrap(), TimePicker::create(0, 0).state.inner.hour);
        assert_eq!(
            u32::try_from(hi24).unwrap(),
            TimePicker::create(u32::MAX, 0).state.inner.hour,
        );
        let (lo12, hi12) = TimePickerState {
            is_24h: false,
            ..TimePickerState::default()
        }
        .hour_bounds();
        assert_eq!(
            u32::try_from(lo12).unwrap(),
            TimePicker::create(0, 0).with_24h(false).state.inner.hour,
        );
        assert_eq!(
            u32::try_from(hi12).unwrap(),
            TimePicker::create(u32::MAX, 0).with_24h(false).state.inner.hour,
        );
    }
    // ==================================================================
    // TimePicker::create
    // ==================================================================
    #[test]
    fn create_clamps_rather_than_wraps_at_every_extreme() {
        // Saturation, not `% 24` / `% 60`: u32::MAX must land on the top of the
        // band, not on `u32::MAX % 24 == 3`.
        for (hour, minute, want_h, want_m) in [
            (0u32, 0u32, 0u32, 0u32),
            (23, 59, 23, 59),
            (24, 60, 23, 59),
            (25, 61, 23, 59),
            (99, 99, 23, 59),
            (u32::MAX - 1, u32::MAX - 1, 23, 59),
            (u32::MAX, u32::MAX, 23, 59),
        ] {
            let p = TimePicker::create(hour, minute);
            assert_eq!(
                (p.state.inner.hour, p.state.inner.minute),
                (want_h, want_m),
                "create({hour}, {minute}) was not clamped into range",
            );
        }
        assert_ne!(
            TimePicker::create(u32::MAX, u32::MAX).state.inner.hour,
            u32::MAX % 24,
            "create wrapped the hour instead of clamping it",
        );
        assert_ne!(
            TimePicker::create(u32::MAX, u32::MAX).state.inner.minute,
            u32::MAX % 60,
            "create wrapped the minute instead of clamping it",
        );
    }
    #[test]
    fn create_lands_inside_its_own_bounds_for_every_input_it_is_given() {
        for hour in [0u32, 1, 12, 23, 24, 1000, u32::MAX / 3, u32::MAX] {
            for minute in [0u32, 1, 30, 59, 60, 12345, u32::MAX] {
                let p = TimePicker::create(hour, minute);
                let (lo, hi) = p.state.inner.hour_bounds();
                let h = i64::from(p.state.inner.hour);
                assert!(
                    (lo..=hi).contains(&h),
                    "create({hour}, {minute}) left hour {h} outside {lo}..={hi}",
                );
                assert!(
                    p.state.inner.minute <= 59,
                    "create({hour}, {minute}) left minute {} outside 0..=59",
                    p.state.inner.minute,
                );
            }
        }
    }
    #[test]
    fn create_passes_every_in_range_value_through_untouched() {
        for hour in 0..24u32 {
            for minute in [0u32, 1, 7, 30, 58, 59] {
                let p = TimePicker::create(hour, minute);
                assert_eq!(
                    (p.state.inner.hour, p.state.inner.minute),
                    (hour, minute),
                    "create mangled the in-range value {hour}:{minute}",
                );
            }
        }
    }
    #[test]
    fn create_always_starts_in_24h_am_without_a_callback() {
        for (hour, minute) in [(0u32, 0u32), (13, 45), (u32::MAX, u32::MAX)] {
            let p = TimePicker::create(hour, minute);
            assert!(p.state.inner.is_24h, "create({hour}, {minute}) did not start in 24-hour mode");
            assert!(!p.state.inner.is_pm, "create({hour}, {minute}) started in PM");
            assert!(
                p.state.on_change.as_ref().is_none(),
                "create({hour}, {minute}) installed a callback nobody asked for",
            );
        }
    }
    #[test]
    fn create_zero_is_exactly_the_default_picker() {
        assert_eq!(TimePicker::create(0, 0), TimePicker::default());
        assert_eq!(TimePicker::create(0, 0).state.inner, TimePickerState::default());
        assert_eq!(TimePicker::default().state, TimePickerStateWrapper::default());
    }
    #[test]
    fn create_uses_the_shared_const_container_style() {
        // A per-instance style vec would allocate on every rebuild; the widget is
        // deliberately built from a `'static` slice.
        let p = TimePicker::create(9, 15);
        assert_eq!(
            properties(&p.container_style),
            CONTAINER_STYLE
                .iter()
                .map(|c| c.property.clone())
                .collect::<Vec<_>>(),
            "the container style is not the shared const declaration",
        );
    }
    #[test]
    fn create_is_deterministic() {
        for (h, m) in [(0u32, 0u32), (7, 8), (u32::MAX, u32::MAX)] {
            assert_eq!(TimePicker::create(h, m), TimePicker::create(h, m));
        }
    }
    // ==================================================================
    // TimePicker::set_24h / with_24h
    // ==================================================================
    #[test]
    fn set_24h_re_clamps_the_hour_into_the_new_band() {
        for (hour, want) in [(0u32, 1u32), (1, 1), (11, 11), (12, 12), (13, 12), (23, 12)] {
            let mut p = TimePicker::create(hour, 0);
            p.set_24h(false);
            assert_eq!(
                p.state.inner.hour, want,
                "switching {hour}:00 to 12-hour mode did not clamp to {want}",
            );
        }
    }
    #[test]
    fn set_24h_clamps_afternoon_hours_instead_of_converting_them() {
        // FINDING (pinned as documented behaviour): the doc says "re-clamping",
        // and that is exactly what happens — 13:00..23:00 all collapse onto 12,
        // and since `is_pm` is left alone the widget then reads back as 12 AM
        // (canonical hour 0), i.e. thirteen hours earlier. A host that toggles a
        // 24-hour picker into 12-hour mode must convert the hour itself.
        let mut p = TimePicker::create(13, 30);
        assert_eq!(p.state.inner.canonical_hour(), 13);
        p.set_24h(false);
        assert_eq!(p.state.inner.hour, 12, "13:00 was not clamped to 12");
        assert!(!p.state.inner.is_pm, "set_24h invented a PM flag");
        assert_eq!(
            p.state.inner.canonical_hour(),
            0,
            "the clamp is expected to read back as midnight, not as 13:00",
        );
    }
    #[test]
    fn set_24h_is_idempotent() {
        for start in [0u32, 5, 13, 23] {
            for target in [false, true] {
                let mut once = TimePicker::create(start, 30);
                once.set_24h(target);
                let mut twice = TimePicker::create(start, 30);
                twice.set_24h(target);
                twice.set_24h(target);
                assert_eq!(
                    once.state.inner, twice.state.inner,
                    "set_24h({target}) is not idempotent from {start}:30",
                );
            }
        }
    }
    #[test]
    fn set_24h_round_trip_is_the_identity_only_inside_the_narrow_band() {
        // 1..=12 survive a 24h -> 12h -> 24h round trip; 0 and 13..=23 do not,
        // because the intermediate 12-hour band cannot represent them.
        for hour in 0..24u32 {
            let mut p = TimePicker::create(hour, 0);
            p.set_24h(false);
            p.set_24h(true);
            if (1..=12).contains(&hour) {
                assert_eq!(p.state.inner.hour, hour, "the round trip lost hour {hour}");
            } else {
                let want = if hour == 0 { 1 } else { 12 };
                assert_eq!(
                    p.state.inner.hour, want,
                    "hour {hour} did not collapse onto {want} as the clamp dictates",
                );
            }
        }
    }
    #[test]
    fn set_24h_never_touches_the_minute_the_pm_flag_or_the_style() {
        for target in [false, true] {
            let before = TimePicker::create(9, 41).with_pm(true);
            let mut after = before.clone();
            after.set_24h(target);
            assert_eq!(after.state.inner.minute, before.state.inner.minute, "the minute moved");
            assert_eq!(after.state.inner.is_pm, before.state.inner.is_pm, "the PM flag moved");
            assert_eq!(
                properties(&after.container_style),
                properties(&before.container_style),
                "switching modes restyled the container",
            );
        }
    }
    #[test]
    fn set_24h_leaves_a_hand_written_out_of_range_hour_inside_the_band() {
        // `hour` is public, so the widget must survive a host writing garbage into
        // it — the `i64` clamp is what keeps the `as u32` cast from wrapping.
        for hour in [0u32, 24, 100, u32::MAX / 2, u32::MAX - 1, u32::MAX] {
            for target in [false, true] {
                let mut p = TimePicker::create(0, 0);
                p.state.inner.hour = hour;
                p.set_24h(target);
                let (lo, hi) = p.state.inner.hour_bounds();
                let h = i64::from(p.state.inner.hour);
                assert!(
                    (lo..=hi).contains(&h),
                    "set_24h({target}) left hand-written hour {hour} at {h}, outside {lo}..={hi}",
                );
            }
        }
    }
    #[test]
    fn with_24h_is_exactly_set_24h_in_builder_form() {
        for target in [false, true] {
            for hour in [0u32, 6, 13, 23] {
                let built = TimePicker::create(hour, 12).with_24h(target);
                let mut set = TimePicker::create(hour, 12);
                set.set_24h(target);
                assert_eq!(built, set, "with_24h({target}) diverged from set_24h at {hour}:12");
            }
        }
    }
    #[test]
    fn with_24h_keeps_the_post_construction_invariants() {
        for target in [false, true] {
            let p = TimePicker::create(u32::MAX, u32::MAX).with_24h(target);
            assert_eq!(p.state.inner.is_24h, target, "the mode flag was not stored");
            let (lo, hi) = p.state.inner.hour_bounds();
            assert!((lo..=hi).contains(&i64::from(p.state.inner.hour)));
            assert!(p.state.inner.minute <= 59);
        }
    }
    // ==================================================================
    // TimePicker::set_pm / with_pm
    // ==================================================================
    #[test]
    fn set_pm_moves_nothing_but_the_flag() {
        for target in [false, true] {
            for is_24h in [false, true] {
                let before = TimePicker::create(11, 22).with_24h(is_24h);
                let mut after = before.clone();
                after.set_pm(target);
                assert_eq!(after.state.inner.is_pm, target, "the PM flag was not stored");
                assert_eq!(after.state.inner.hour, before.state.inner.hour, "the hour moved");
                assert_eq!(after.state.inner.minute, before.state.inner.minute, "the minute moved");
                assert_eq!(after.state.inner.is_24h, before.state.inner.is_24h, "the mode moved");
            }
        }
    }
    #[test]
    fn set_pm_is_idempotent_and_two_flips_are_the_identity() {
        let mut p = TimePicker::create(4, 4).with_24h(false);
        p.set_pm(true);
        let once = p.state.inner;
        p.set_pm(true);
        assert_eq!(p.state.inner, once, "set_pm(true) is not idempotent");
        p.set_pm(false);
        p.set_pm(true);
        assert_eq!(p.state.inner, once, "two flips did not return to the same state");
    }
    #[test]
    fn set_pm_does_not_re_clamp_an_out_of_band_hour() {
        // `set_pm` is a `const fn` field write by design; it must not silently
        // start doing the clamping that only `set_24h` / `create` do.
        let mut p = TimePicker::create(0, 0);
        p.state.inner.hour = u32::MAX;
        p.set_pm(true);
        assert_eq!(p.state.inner.hour, u32::MAX, "set_pm re-clamped the hour");
    }
    #[test]
    fn with_pm_is_exactly_set_pm_in_builder_form() {
        for target in [false, true] {
            let built = TimePicker::create(3, 33).with_pm(target);
            let mut set = TimePicker::create(3, 33);
            set.set_pm(target);
            assert_eq!(built, set, "with_pm({target}) diverged from set_pm");
        }
    }
    #[test]
    fn pm_in_24h_mode_is_inert_for_the_canonical_reading() {
        // The docs call the flag "only meaningful in 12-hour mode"; a 24-hour
        // widget must therefore read the same with it set or clear.
        let am = TimePicker::create(15, 0);
        let pm = TimePicker::create(15, 0).with_pm(true);
        assert_eq!(
            am.state.inner.canonical_hour(),
            pm.state.inner.canonical_hour(),
            "the PM flag changed a 24-hour reading",
        );
        assert_eq!(am.state.inner.hour, pm.state.inner.hour);
    }
    // ==================================================================
    // TimePicker::set_on_change / with_on_change
    // ==================================================================
    #[test]
    fn set_on_change_stores_the_function_pointer_and_the_payload_verbatim() {
        let mut p = TimePicker::create(1, 1);
        p.set_on_change(
            RefAny::new(0xDEAD_BEEF_u32),
            change_do_nothing as TimePickerOnChangeCallbackType,
        );
        let c = p
            .state
            .on_change
            .as_ref()
            .expect("set_on_change did not store anything");
        assert_eq!(
            c.callback.cb as *const () as usize,
            change_do_nothing as *const () as usize,
            "the stored function pointer is not the one that was handed in",
        );
        assert!(
            matches!(c.callback.ctx, OptionRefAny::None),
            "a native Rust callback must not carry an FFI context",
        );
        let mut payload = c.refany.clone();
        assert_eq!(
            *payload.downcast_ref::<u32>().expect("the payload changed type"),
            0xDEAD_BEEF,
        );
    }
    #[test]
    fn set_on_change_replaces_rather_than_accumulates() {
        let mut p = TimePicker::create(1, 1);
        p.set_on_change(RefAny::new(1u8), change_do_nothing as TimePickerOnChangeCallbackType);
        p.set_on_change(RefAny::new(2u8), change_refresh_all as TimePickerOnChangeCallbackType);
        let c = p.state.on_change.as_ref().expect("the callback vanished");
        assert_eq!(
            c.callback.cb as *const () as usize,
            change_refresh_all as *const () as usize,
            "the second set_on_change did not win",
        );
        let mut payload = c.refany.clone();
        assert_eq!(*payload.downcast_ref::<u8>().expect("wrong payload type"), 2);
    }
    #[test]
    fn set_on_change_does_not_disturb_the_time_or_the_container_style() {
        let before = TimePicker::create(23, 59);
        let mut after = TimePicker::create(23, 59);
        after.set_on_change(RefAny::new(0u8), change_do_nothing as TimePickerOnChangeCallbackType);
        assert_eq!(after.state.inner, before.state.inner, "installing a callback moved the time");
        assert_eq!(
            properties(&after.container_style),
            properties(&before.container_style),
            "installing a callback restyled the container",
        );
    }
    #[test]
    fn with_on_change_is_exactly_set_on_change_in_builder_form() {
        let built = TimePicker::create(5, 9)
            .with_on_change(RefAny::new(7u32), change_do_nothing as TimePickerOnChangeCallbackType);
        let mut set = TimePicker::create(5, 9);
        set.set_on_change(RefAny::new(7u32), change_do_nothing as TimePickerOnChangeCallbackType);
        assert_eq!(built.state.inner, set.state.inner);
        let a = built.state.on_change.as_ref().expect("builder dropped the callback");
        let b = set.state.on_change.as_ref().expect("setter dropped the callback");
        assert_eq!(a.callback.cb as *const () as usize, b.callback.cb as *const () as usize);
        let (mut pa, mut pb) = (a.refany.clone(), b.refany.clone());
        assert_eq!(
            *pa.downcast_ref::<u32>().expect("builder payload changed type"),
            *pb.downcast_ref::<u32>().expect("setter payload changed type"),
        );
    }
    #[test]
    fn with_on_change_accepts_a_generic_callback_without_mangling_the_pointer() {
        // The `From<Callback>` arm *transmutes* a 2-arg fn pointer into the 3-arg
        // time-picker slot — this is the FFI (Python/C) path. The pointer must
        // come out bit-identical; a mangled one would be a wild jump on the first
        // arrow click.
        let generic = Callback {
            cb: generic_shaped,
            ctx: OptionRefAny::None,
        };
        let expected = generic_shaped as *const () as usize;
        let p = TimePicker::create(1, 1).with_on_change(RefAny::new(0u8), generic);
        let c = p.state.on_change.as_ref().expect("the generic callback was dropped");
        assert_eq!(
            c.callback.cb as *const () as usize,
            expected,
            "the Callback -> TimePickerOnChangeCallback transmute mangled the pointer",
        );
    }
    #[test]
    fn installing_a_callback_survives_every_other_builder_step() {
        // Order-independence: the builders are documented as composable, so a
        // callback must not be dropped by a later `with_24h` / `with_pm`.
        let p = TimePicker::create(9, 30)
            .with_on_change(RefAny::new(1u8), change_do_nothing as TimePickerOnChangeCallbackType)
            .with_24h(false)
            .with_pm(true);
        assert!(p.state.on_change.as_ref().is_some(), "a later builder dropped the callback");
        assert_eq!(p.state.inner.hour, 9);
        assert!(p.state.inner.is_pm);
        assert!(!p.state.inner.is_24h);
    }
    // ==================================================================
    // TimePicker::swap_with_default
    // ==================================================================
    #[test]
    fn swap_with_default_hands_out_the_original_and_leaves_a_default_behind() {
        let mut p = TimePicker::create(21, 45).with_24h(false).with_pm(true);
        let inner_before = p.state.inner;
        let taken = p.swap_with_default();
        assert_eq!(taken.state.inner, inner_before, "the original state was not handed out");
        assert_eq!(p, TimePicker::default(), "the picker left behind is not a default one");
    }
    #[test]
    fn swap_with_default_carries_the_callback_out_with_the_original() {
        // If the callback stayed behind on the *default* picker, the host would
        // keep getting change notifications from a widget it thought it had taken.
        let mut p = TimePicker::create(6, 6)
            .with_on_change(RefAny::new(3u8), change_do_nothing as TimePickerOnChangeCallbackType);
        let taken = p.swap_with_default();
        assert!(
            taken.state.on_change.as_ref().is_some(),
            "the callback did not leave with the original",
        );
        assert!(
            p.state.on_change.as_ref().is_none(),
            "the callback stayed behind on the default",
        );
    }
    #[test]
    fn swapping_a_default_repeatedly_is_idempotent() {
        let mut p = TimePicker::default();
        let first = p.swap_with_default();
        let second = p.swap_with_default();
        assert_eq!(first, TimePicker::default());
        assert_eq!(second, TimePicker::default());
        assert_eq!(p, TimePicker::default());
    }
    #[test]
    fn swap_with_default_survives_a_hand_written_out_of_range_state() {
        let mut p = TimePicker::create(0, 0);
        p.state.inner.hour = u32::MAX;
        p.state.inner.minute = u32::MAX;
        let taken = p.swap_with_default();
        assert_eq!(taken.state.inner.hour, u32::MAX, "swap normalised what it took out");
        assert_eq!(p.state.inner, TimePickerState::default(), "the replacement is not a default");
    }
    // ==================================================================
    // TimePicker::dom
    // ==================================================================
    #[test]
    fn dom_renders_three_columns_in_24h_mode_and_four_in_12h() {
        let h24 = TimePicker::create(9, 5).dom();
        assert!(matches!(h24.root.get_node_type(), NodeType::Div));
        assert_eq!(classes(&h24), vec![CLASS_CONTAINER.to_string()]);
        assert_eq!(h24.children.as_ref().len(), 3, "24-hour mode rendered an AM/PM toggle");
        let h12 = TimePicker::create(9, 5).with_24h(false).dom();
        assert_eq!(h12.children.as_ref().len(), 4, "12-hour mode did not render an AM/PM toggle");
        assert_eq!(classes(&h12.children.as_ref()[3]), vec![CLASS_AMPM.to_string()]);
    }
    #[test]
    fn dom_columns_carry_the_documented_classes_and_glyphs() {
        let dom = TimePicker::create(9, 5).dom();
        let (hour, sep, minute) = columns(&dom);
        assert_eq!(classes(sep), vec![CLASS_SEPARATOR.to_string()]);
        assert_eq!(text_of(sep).as_deref(), Some(":"), "the separator is not a colon");
        for (which, col) in [("hour", hour), ("minute", minute)] {
            assert_eq!(classes(col), vec![CLASS_SPINNER.to_string()], "{which}: wrong class");
            let cells = col.children.as_ref();
            assert_eq!(cells.len(), 3, "{which}: a spinner is up-arrow / value / down-arrow");
            assert_eq!(classes(&cells[0]), vec![CLASS_ARROW.to_string()]);
            assert_eq!(classes(&cells[1]), vec![CLASS_DISPLAY.to_string()]);
            assert_eq!(classes(&cells[2]), vec![CLASS_ARROW.to_string()]);
            assert_eq!(text_of(&cells[0]).as_deref(), Some(UP_GLYPH), "{which}: up arrow");
            assert_eq!(text_of(&cells[2]).as_deref(), Some(DOWN_GLYPH), "{which}: down arrow");
        }
    }
    #[test]
    fn the_class_and_glyph_constants_are_the_ones_the_widget_declares() {
        // The strings above are hard-coded so a rename shows up as a test failure
        // rather than silently breaking every user stylesheet.
        let names = |c: &[IdOrClass]| -> Vec<String> {
            c.iter()
                .filter_map(|c| match c {
                    Class(s) => Some(s.as_str().to_string()),
                    IdOrClass::Id(_) => None,
                })
                .collect()
        };
        assert_eq!(names(TIME_PICKER_CLASS), vec![CLASS_CONTAINER.to_string()]);
        assert_eq!(names(SPINNER_CLASS), vec![CLASS_SPINNER.to_string()]);
        assert_eq!(names(DISPLAY_CLASS), vec![CLASS_DISPLAY.to_string()]);
        assert_eq!(names(ARROW_CLASS), vec![CLASS_ARROW.to_string()]);
        assert_eq!(names(SEPARATOR_CLASS), vec![CLASS_SEPARATOR.to_string()]);
        assert_eq!(names(AMPM_CLASS), vec![CLASS_AMPM.to_string()]);
        assert_eq!(UP_ARROW.as_str(), UP_GLYPH);
        assert_eq!(DOWN_ARROW.as_str(), DOWN_GLYPH);
        assert_eq!(SEPARATOR_TEXT.as_str(), ":");
    }
    #[test]
    fn dom_zero_pads_the_minute_but_not_the_hour() {
        // FINDING (pinned): the two displays disagree — `{}` for the hour and
        // `{:02}` for the minute — so 09:05 renders as "9:05". Deliberate or not,
        // the arrow handlers re-text with the same asymmetric formats, so at least
        // the widget is self-consistent.
        assert_eq!(
            displayed(&TimePicker::create(9, 5).dom()),
            ("9".to_string(), "05".to_string()),
        );
        assert_eq!(
            displayed(&TimePicker::create(0, 0).dom()),
            ("0".to_string(), "00".to_string()),
        );
        assert_eq!(
            displayed(&TimePicker::create(23, 59).dom()),
            ("23".to_string(), "59".to_string()),
        );
    }
    #[test]
    fn dom_text_round_trips_the_whole_state_for_every_hour_and_minute() {
        // encode (state -> displayed text) == decode (parse the text back): the
        // rendered digits must be exactly the stored value, never a rounded or
        // re-derived one.
        for hour in 0..24u32 {
            for minute in [0u32, 1, 9, 10, 30, 58, 59] {
                let (h, m) = displayed(&TimePicker::create(hour, minute).dom());
                assert_eq!(
                    h.parse::<u32>().expect("the hour display is not a number"),
                    hour,
                    "the hour display drifted at {hour}:{minute}",
                );
                assert_eq!(
                    m.parse::<u32>().expect("the minute display is not a number"),
                    minute,
                    "the minute display drifted at {hour}:{minute}",
                );
                assert_eq!(m.chars().count(), 2, "the minute is not zero-padded at {hour}:{minute}");
            }
        }
    }
    #[test]
    fn dom_renders_a_hand_written_out_of_range_state_without_panicking() {
        // `dom()` does not clamp — it formats whatever the state holds. The point
        // is that it stays total: no panic, no truncation, no wrap.
        let mut p = TimePicker::create(0, 0);
        p.state.inner.hour = u32::MAX;
        p.state.inner.minute = u32::MAX;
        let (h, m) = displayed(&p.dom());
        assert_eq!(h, u32::MAX.to_string(), "the hour display truncated a huge value");
        assert_eq!(m, u32::MAX.to_string(), "the minute display truncated a huge value");
    }
    #[test]
    fn dom_ampm_label_tracks_the_pm_flag_and_only_exists_in_12h_mode() {
        assert_eq!(
            ampm_label(&TimePicker::create(6, 0).with_24h(false).dom()).as_deref(),
            Some("AM"),
        );
        assert_eq!(
            ampm_label(&TimePicker::create(6, 0).with_24h(false).with_pm(true).dom()).as_deref(),
            Some("PM"),
        );
        // The flag is still set here, but 24-hour mode must not render a toggle.
        assert_eq!(ampm_label(&TimePicker::create(6, 0).with_pm(true).dom()), None);
    }
    #[test]
    fn dom_wires_each_of_the_five_handlers_exactly_once() {
        let styled = StyledDom::create_from_dom(TimePicker::create(8, 8).with_24h(false).dom());
        for (name, handler) in [
            ("on_hour_up", on_hour_up as usize),
            ("on_hour_down", on_hour_down as usize),
            ("on_minute_up", on_minute_up as usize),
            ("on_minute_down", on_minute_down as usize),
            ("on_ampm_toggle", on_ampm_toggle as usize),
        ] {
            let count = styled
                .node_data
                .as_ref()
                .iter()
                .flat_map(|nd| nd.callbacks.as_ref().iter())
                .filter(|cb| cb.callback.cb == handler)
                .count();
            assert_eq!(count, 1, "{name} is wired to {count} node(s), not exactly one");
        }
    }
    #[test]
    fn dom_registers_every_handler_on_mouse_up_and_makes_the_cell_focusable() {
        let styled = StyledDom::create_from_dom(TimePicker::create(8, 8).with_24h(false).dom());
        let mut interactive = 0;
        for nd in styled.node_data.as_ref() {
            for cb in nd.callbacks.as_ref() {
                assert_eq!(
                    cb.event,
                    EventFilter::Hover(HoverEventFilter::MouseUp),
                    "a time-picker cell fires on something other than mouse-up",
                );
                assert!(
                    matches!(cb.callback.ctx, OptionRefAny::None),
                    "a native handler carries an FFI context",
                );
                interactive += 1;
            }
            if !nd.callbacks.as_ref().is_empty() {
                assert_eq!(
                    nd.flags.get_tab_index(),
                    Some(TabIndex::Auto),
                    "a clickable time-picker cell is not keyboard-focusable",
                );
            }
        }
        assert_eq!(interactive, 5, "12-hour mode must expose 4 arrows + 1 AM/PM toggle");
    }
    #[test]
    fn dom_leaves_the_displays_and_the_separator_inert() {
        // Only the arrows and the toggle are clickable; a handler on a display
        // would fire on a click meant to select the text.
        let styled = StyledDom::create_from_dom(TimePicker::create(8, 8).with_24h(false).dom());
        for idx in [
            N_CONTAINER,
            N_HOUR_SPINNER,
            N_HOUR_DISPLAY,
            N_SEPARATOR,
            N_MINUTE_SPINNER,
            N_MINUTE_DISPLAY,
        ] {
            assert!(
                styled.node_data.as_ref()[idx].callbacks.as_ref().is_empty(),
                "flattened node {idx} registered a click handler it should not have",
            );
        }
    }
    #[test]
    fn dom_shares_one_state_refany_across_every_handler() {
        // Four arrows and the toggle must all mutate the *same* state; a per-cell
        // copy would let the hour and the minute drift apart.
        let styled = StyledDom::create_from_dom(TimePicker::create(5, 5).with_24h(false).dom());
        let payloads = state_payloads(&styled);
        assert_eq!(payloads.len(), 5, "not every handler carries the widget state");
        {
            let mut first = payloads[0].clone();
            let mut w = first
                .downcast_mut::<TimePickerStateWrapper>()
                .expect("the state changed type");
            w.inner.minute = 42;
        }
        for (i, p) in payloads.iter().enumerate() {
            assert_eq!(
                read_state(p).minute,
                42,
                "handler payload #{i} is a private copy of the state",
            );
        }
    }
    #[test]
    fn dom_keeps_its_cached_child_count_in_sync_with_the_tree() {
        // `estimated_total_children` is a cache; if it under-reports, the flatten
        // under-allocates its arenas and panics on an out-of-bounds write.
        for is_24h in [false, true] {
            for (h, m) in [(0u32, 0u32), (23, 59), (u32::MAX, u32::MAX)] {
                let dom = TimePicker::create(h, m).with_24h(is_24h).dom();
                let expected_nodes = if is_24h { N_NODES_24H } else { N_NODES_12H };
                assert_eq!(
                    dom.estimated_total_children,
                    descendants(&dom),
                    "{h}:{m} 24h={is_24h}: the cached descendant count is wrong",
                );
                let styled = StyledDom::create_from_dom(dom);
                assert_eq!(
                    styled.node_data.as_ref().len(),
                    expected_nodes,
                    "{h}:{m} 24h={is_24h}: the widget did not flatten to {expected_nodes} nodes",
                );
            }
        }
    }
    #[test]
    fn flattened_layout_is_the_fixed_seventeen_or_nineteen_nodes() {
        // Pins the pre-order indices the click tests below index by name.
        let styled = StyledDom::create_from_dom(TimePicker::create(7, 8).with_24h(false).dom());
        for (idx, class, text) in [
            (N_CONTAINER, CLASS_CONTAINER, None),
            (N_HOUR_SPINNER, CLASS_SPINNER, None),
            (N_HOUR_UP, CLASS_ARROW, Some(UP_GLYPH)),
            (N_HOUR_DISPLAY, CLASS_DISPLAY, Some("7")),
            (N_HOUR_DOWN, CLASS_ARROW, Some(DOWN_GLYPH)),
            (N_SEPARATOR, CLASS_SEPARATOR, Some(":")),
            (N_MINUTE_SPINNER, CLASS_SPINNER, None),
            (N_MINUTE_UP, CLASS_ARROW, Some(UP_GLYPH)),
            (N_MINUTE_DISPLAY, CLASS_DISPLAY, Some("08")),
            (N_MINUTE_DOWN, CLASS_ARROW, Some(DOWN_GLYPH)),
            (N_AMPM, CLASS_AMPM, Some("AM")),
        ] {
            assert_eq!(
                flat_classes(&styled, idx),
                vec![class.to_string()],
                "flattened node {idx} is not the {class} node",
            );
            assert_eq!(
                flat_text(&styled, idx).as_deref(),
                text,
                "flattened node {idx} renders the wrong text",
            );
        }
        assert_eq!(
            wired_to(&styled, on_hour_up as usize).0,
            node(N_HOUR_UP),
            "the hour up arrow is not where the indices say it is",
        );
        assert_eq!(wired_to(&styled, on_hour_down as usize).0, node(N_HOUR_DOWN));
        assert_eq!(wired_to(&styled, on_minute_up as usize).0, node(N_MINUTE_UP));
        assert_eq!(wired_to(&styled, on_minute_down as usize).0, node(N_MINUTE_DOWN));
        assert_eq!(wired_to(&styled, on_ampm_toggle as usize).0, node(N_AMPM));
    }
    #[test]
    fn from_timepicker_for_dom_is_the_dom_method() {
        let via_from: Dom = TimePicker::create(4, 20).into();
        let via_dom = TimePicker::create(4, 20).dom();
        assert_eq!(classes(&via_from), classes(&via_dom));
        assert_eq!(displayed(&via_from), displayed(&via_dom));
        assert_eq!(via_from.children.as_ref().len(), via_dom.children.as_ref().len());
        assert_eq!(via_from.estimated_total_children, via_dom.estimated_total_children);
    }
    // ==================================================================
    // build_spinner
    // ==================================================================
    #[test]
    fn build_spinner_stores_the_handler_pointers_verbatim_at_the_usize_extremes() {
        // The handlers are erased to `usize` before they reach the DOM, so nothing
        // type-checks them any more. Every value — including 0 and usize::MAX,
        // which are not valid code addresses — must come back out unchanged
        // rather than being validated, folded or truncated.
        for (up, down) in [
            (0usize, 0usize),
            (0, usize::MAX),
            (usize::MAX, 0),
            (usize::MAX, usize::MAX),
            (1, 2),
            (usize::MAX / 2, usize::MAX - 1),
        ] {
            let dom = build_spinner(AzString::from_const_str("0"), RefAny::new(0u8), up, down);
            let cells = dom.children.as_ref();
            assert_eq!(cells.len(), 3);
            assert_eq!(
                cells[0].root.callbacks.as_ref()[0].callback.cb, up,
                "the up handler {up} was mangled",
            );
            assert_eq!(
                cells[2].root.callbacks.as_ref()[0].callback.cb, down,
                "the down handler {down} was mangled",
            );
        }
    }
    #[test]
    fn build_spinner_puts_the_value_between_the_two_arrows() {
        let dom = build_spinner(AzString::from_const_str("42"), RefAny::new(0u8), 1, 2);
        assert_eq!(classes(&dom), vec![CLASS_SPINNER.to_string()]);
        let cells = dom.children.as_ref();
        assert_eq!(text_of(&cells[0]).as_deref(), Some(UP_GLYPH));
        assert_eq!(text_of(&cells[1]).as_deref(), Some("42"));
        assert_eq!(text_of(&cells[2]).as_deref(), Some(DOWN_GLYPH));
        assert!(
            cells[1].root.callbacks.as_ref().is_empty(),
            "the value display must not be clickable",
        );
    }
    #[test]
    fn build_spinner_preserves_every_kind_of_value_string_byte_for_byte() {
        // The value is host-supplied text in the general case, so it must survive
        // empty, NUL-bearing, astral-plane, combining, RTL and very long inputs
        // without being trimmed, escaped or re-encoded.
        let long = "9".repeat(4096);
        let values: Vec<String> = vec![
            String::new(),
            " ".to_string(),
            "\0".to_string(),
            "\n\t".to_string(),
            "٣٠".to_string(),                 // arabic-indic digits
            "\u{1F55B}".to_string(),          // 🕛
            "e\u{0301}".to_string(),          // combining acute
            "\u{202E}12".to_string(),         // RTL override
            "-1".to_string(),
            "٩٩:٩٩".to_string(),
            long,
        ];
        for v in values {
            let dom = build_spinner(AzString::from(v.clone()), RefAny::new(0u8), 1, 2);
            let shown = text_of(&dom.children.as_ref()[1]);
            assert_eq!(
                shown.as_deref(),
                Some(v.as_str()),
                "the spinner value was not preserved verbatim ({} bytes)",
                v.len(),
            );
        }
    }
    #[test]
    fn build_spinner_shares_the_state_between_both_arrows() {
        let state = RefAny::new(TimePickerStateWrapper::default());
        let dom = build_spinner(AzString::from_const_str("0"), state.clone(), 1, 2);
        let cells = dom.children.as_ref();
        {
            let mut up_payload = cells[0].root.callbacks.as_ref()[0].refany.clone();
            let mut w = up_payload
                .downcast_mut::<TimePickerStateWrapper>()
                .expect("the up arrow does not carry the state");
            w.inner.hour = 17;
        }
        let mut down_payload = cells[2].root.callbacks.as_ref()[0].refany.clone();
        let seen = down_payload
            .downcast_ref::<TimePickerStateWrapper>()
            .expect("the down arrow does not carry the state")
            .inner
            .hour;
        assert_eq!(seen, 17, "the two arrows of one spinner hold separate states");
        assert_eq!(read_state(&state).hour, 17, "the caller's own handle was not shared");
    }
    #[test]
    fn build_spinner_makes_both_arrows_focusable_click_targets() {
        let dom = build_spinner(AzString::from_const_str("0"), RefAny::new(0u8), 1, 2);
        for (which, cell) in [("up", 0usize), ("down", 2usize)] {
            let cell = &dom.children.as_ref()[cell];
            let cbs = cell.root.callbacks.as_ref();
            assert_eq!(cbs.len(), 1, "{which}: an arrow registers exactly one handler");
            assert_eq!(cbs[0].event, EventFilter::Hover(HoverEventFilter::MouseUp));
            assert_eq!(
                cell.root.flags.get_tab_index(),
                Some(TabIndex::Auto),
                "{which}: the arrow is not keyboard-focusable",
            );
            assert_eq!(classes(cell), vec![CLASS_ARROW.to_string()]);
        }
    }
    #[test]
    fn build_spinner_reports_its_three_children() {
        for value in ["", "0", "999999"] {
            let dom = build_spinner(AzString::from(value.to_string()), RefAny::new(0u8), 1, 2);
            assert_eq!(dom.estimated_total_children, descendants(&dom));
            // Three cells (▲ / value / ▼), each a styled `<p>` wrapping its
            // bare text leaf per the label convention: 6 descendants.
            assert_eq!(dom.estimated_total_children, 6);
        }
    }
    // ==================================================================
    // adjust_spinner + the four arrow handlers
    // ==================================================================
    #[test]
    fn hour_arrows_move_the_hour_by_one_and_retext_the_hour_display() {
        let (styled, shared) = laid_out(TimePicker::create(9, 30));
        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
        let (update, changes) = press(styled, &payload, hit, on_hour_up);
        assert_eq!(read_state(&shared).hour, 10, "the up arrow did not increment the hour");
        assert_eq!(read_state(&shared).minute, 30, "the hour arrow moved the minute");
        assert_eq!(update, Update::DoNothing, "no callback is installed, so nothing to report");
        assert_eq!(
            only_retext(&changes),
            (node(text_leaf(N_HOUR_DISPLAY)), "10".to_string()),
            "the up arrow retexted the wrong node (or with the wrong text)",
        );
    }
    #[test]
    fn minute_arrows_move_the_minute_and_retext_it_zero_padded() {
        let (styled, shared) = laid_out(TimePicker::create(9, 30));
        let (hit, payload) = wired_to(&styled, on_minute_down as usize);
        let (_, changes) = press(styled, &payload, hit, on_minute_down);
        assert_eq!(read_state(&shared).minute, 29, "the down arrow did not decrement the minute");
        assert_eq!(read_state(&shared).hour, 9, "the minute arrow moved the hour");
        assert_eq!(
            only_retext(&changes),
            (node(text_leaf(N_MINUTE_DISPLAY)), "29".to_string())
        );
        // ... and single digits keep the two-digit form the initial render used.
        let (styled, _) = laid_out(TimePicker::create(9, 10));
        let (hit, payload) = wired_to(&styled, on_minute_down as usize);
        let (_, changes) = press(styled, &payload, hit, on_minute_down);
        assert_eq!(
            only_retext(&changes).1,
            "09",
            "the retext dropped the zero padding the first render used",
        );
    }
    #[test]
    fn the_hour_clamps_at_both_ends_of_the_24_hour_band() {
        for (start, handler, want) in [
            (23u32, on_hour_up as extern "C" fn(RefAny, CallbackInfo) -> Update, 23u32),
            (0, on_hour_down as extern "C" fn(RefAny, CallbackInfo) -> Update, 0),
        ] {
            let (styled, shared) = laid_out(TimePicker::create(start, 0));
            let handler_addr = handler as usize;
            let (hit, payload) = wired_to(&styled, handler_addr);
            let (_, changes) = press(styled, &payload, hit, handler);
            assert_eq!(read_state(&shared).hour, want, "the hour escaped the band from {start}");
            assert_eq!(
                only_retext(&changes).1,
                want.to_string(),
                "the clamped hour was retexted with something else",
            );
        }
    }
    #[test]
    fn the_hour_clamps_to_the_narrow_band_in_12_hour_mode() {
        for (start, handler, want) in [
            (12u32, on_hour_up as extern "C" fn(RefAny, CallbackInfo) -> Update, 12u32),
            (1, on_hour_down as extern "C" fn(RefAny, CallbackInfo) -> Update, 1),
            (11, on_hour_up as extern "C" fn(RefAny, CallbackInfo) -> Update, 12),
            (2, on_hour_down as extern "C" fn(RefAny, CallbackInfo) -> Update, 1),
        ] {
            let (styled, shared) = laid_out(TimePicker::create(start, 0).with_24h(false));
            let (hit, payload) = wired_to(&styled, handler as usize);
            let (_, _) = press(styled, &payload, hit, handler);
            assert_eq!(
                read_state(&shared).hour,
                want,
                "12-hour mode: pressing from {start} did not land on {want}",
            );
        }
    }
    #[test]
    fn the_minute_clamps_and_never_carries_into_the_hour() {
        // The module's documented PARTIAL: 59 + 1 stays 59 and 0 - 1 stays 0 —
        // the hour must not move either way.
        for (start, handler, want) in [
            (59u32, on_minute_up as extern "C" fn(RefAny, CallbackInfo) -> Update, 59u32),
            (0, on_minute_down as extern "C" fn(RefAny, CallbackInfo) -> Update, 0),
        ] {
            let (styled, shared) = laid_out(TimePicker::create(12, start));
            let (hit, payload) = wired_to(&styled, handler as usize);
            let (_, changes) = press(styled, &payload, hit, handler);
            assert_eq!(read_state(&shared).minute, want, "the minute escaped 0..=59 from {start}");
            assert_eq!(read_state(&shared).hour, 12, "the minute carried into the hour");
            assert_eq!(
                only_retext(&changes),
                (node(N_MINUTE_TEXT), format!("{want:02}")),
                "the clamped minute was retexted wrongly",
            );
        }
    }
    #[test]
    fn holding_an_arrow_walks_the_whole_band_and_then_stops() {
        // 200 presses is far more than the band is wide: the value must saturate
        // at the edge rather than wrapping around or running away.
        let (styled, shared) = laid_out(TimePicker::create(0, 0));
        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
        press_n(styled, &payload, hit, on_hour_up, 200);
        assert_eq!(read_state(&shared).hour, 23, "200 up-presses did not saturate at 23");
        let (styled, shared) = laid_out(TimePicker::create(23, 59));
        let (hit, payload) = wired_to(&styled, on_minute_down as usize);
        press_n(styled, &payload, hit, on_minute_down, 200);
        assert_eq!(read_state(&shared).minute, 0, "200 down-presses did not saturate at 0");
        assert_eq!(read_state(&shared).hour, 23, "the saturating minute borrowed from the hour");
    }
    #[test]
    fn every_step_of_a_full_sweep_is_reported_exactly_once() {
        // Walking 0 -> 23 must push 23 distinct retexts in order and land on 23;
        // a duplicated or skipped step would desynchronise the display from the
        // state, which is the whole failure mode `change_node_text` exists to avoid.
        let (styled, shared) = laid_out(TimePicker::create(0, 0));
        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
        let (_, changes) = press_n(styled, &payload, hit, on_hour_up, 23);
        let texts: Vec<String> = pushed_texts(&changes).into_iter().map(|(_, t)| t).collect();
        assert_eq!(
            texts,
            (1..=23u32).map(|h| h.to_string()).collect::<Vec<_>>(),
            "the sweep did not report every hour exactly once, in order",
        );
        assert_eq!(read_state(&shared).hour, 23);
    }
    #[test]
    fn a_press_on_a_node_without_a_parent_changes_nothing_at_all() {
        // The container has no parent, so the display lookup fails. The state must
        // be left alone — the handler bails out *before* it touches the value.
        let (styled, shared) = laid_out(TimePicker::create(9, 30));
        let (_, payload) = wired_to(&styled, on_hour_up as usize);
        let (update, changes) = press(styled, &payload, node(N_CONTAINER), on_hour_up);
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "a parentless hit still retexted something");
        assert_eq!(
            read_state(&shared),
            TimePicker::create(9, 30).state.inner,
            "a hit that could not be resolved mutated the state anyway",
        );
    }
    #[test]
    fn a_press_on_a_detached_or_out_of_range_node_changes_nothing_at_all() {
        for (name, hit) in [
            ("no node at all", node_none()),
            ("one past the end", node(N_NODES_24H)),
            ("far out of range", node(usize::MAX / 2)),
        ] {
            let (styled, shared) = laid_out(TimePicker::create(9, 30));
            let (_, payload) = wired_to(&styled, on_minute_up as usize);
            let (update, changes) = press(styled, &payload, hit, on_minute_up);
            assert_eq!(update, Update::DoNothing, "{name}: unexpected verdict");
            assert!(changes.is_empty(), "{name}: a bad hit still retexted something");
            assert_eq!(
                read_state(&shared).minute,
                30,
                "{name}: a bad hit mutated the state",
            );
        }
    }
    #[test]
    fn the_display_lookup_is_positional_and_trusts_whatever_node_it_lands_on() {
        // The walk is `hit -> parent -> first child -> next sibling` with no check
        // that the result is a display cell. Delivered on the separator (whose
        // parent is the *container*), it resolves to the separator itself and
        // overwrites the ":" with the hour. Unreachable through `dom()` — only the
        // arrows are wired — but pinned so the lack of validation is visible, and
        // so a future rewiring of the separator shows up here.
        let (styled, shared) = laid_out(TimePicker::create(9, 30));
        let (_, payload) = wired_to(&styled, on_hour_up as usize);
        let (update, changes) = press(styled, &payload, node(N_SEPARATOR), on_hour_up);
        assert_eq!(update, Update::DoNothing);
        assert_eq!(read_state(&shared).hour, 10, "the edit itself did not happen");
        assert_eq!(read_state(&shared).minute, 30, "an hour press moved the minute");
        assert_eq!(
            only_retext(&changes),
            (node(text_leaf(N_SEPARATOR)), "10".to_string()),
            "the positional lookup landed somewhere other than the separator",
        );
    }
    #[test]
    fn a_press_with_a_foreign_payload_is_declined_without_touching_the_dom() {
        // The `RefAny` is type-erased: a host that wires the wrong payload onto an
        // arrow must get a clean no-op, not a wild reinterpretation of the bytes.
        for payload in [
            RefAny::new(0u8),
            RefAny::new(TimePickerState::default()),
            RefAny::new(String::from("not a time picker")),
        ] {
            let (styled, shared) = laid_out(TimePicker::create(9, 30));
            let (hit, _) = wired_to(&styled, on_hour_up as usize);
            let (update, changes) = press(styled, &payload, hit, on_hour_up);
            assert_eq!(update, Update::DoNothing, "a foreign payload was accepted");
            assert!(changes.is_empty(), "a foreign payload still retexted the display");
            assert_eq!(read_state(&shared).hour, 9, "a foreign payload moved the real state");
        }
    }
    #[test]
    fn adjust_spinner_saturates_at_the_extreme_deltas_it_can_be_handed() {
        // `delta` is an `i64` added to `i64::from(hour)`. From hour 0 the whole
        // `i64` range is representable, so both extremes must land on the ends of
        // the band rather than wrapping.
        //
        // NOTE: `i64::from(hour) + delta` is a plain `+`. It cannot overflow for
        // any delta the widget itself passes (±1), but a delta near `i64::MAX`
        // combined with a non-zero hour would — see the report.
        for (delta, want_hour, want_minute) in [
            (i64::MAX, 23u32, 59u32),
            (i64::MIN, 0, 0),
            (i64::MAX / 2, 23, 59),
            (i64::MIN / 2, 0, 0),
            (1_000_000_000, 23, 59),
            (-1_000_000_000, 0, 0),
        ] {
            let (styled, _) = laid_out(TimePicker::create(0, 0));
            let (hit, _) = wired_to(&styled, on_hour_up as usize);
            let state = wrapper(TimePickerState::default());
            let (_, _) = with_info(styled, hit, |info| {
                let _ = adjust_spinner(state.clone(), *info, true, delta);
                let _ = adjust_spinner(state.clone(), *info, false, delta);
            });
            assert_eq!(
                (read_state(&state).hour, read_state(&state).minute),
                (want_hour, want_minute),
                "delta {delta} did not saturate",
            );
        }
    }
    #[test]
    fn adjust_spinner_saturates_a_hand_written_extreme_hour_back_into_the_band() {
        // `hour = u32::MAX` plus `delta = i64::MIN` is the widest gap the clamp
        // has to close; `i64::from(u32::MAX) + i64::MIN` stays inside `i64`.
        for (start_hour, delta, want) in [
            (u32::MAX, i64::MIN, 0u32),
            (u32::MAX, -1, 23),
            (u32::MAX, 0, 23),
            (u32::MAX, 1, 23),
            (0, -1, 0),
        ] {
            let (styled, _) = laid_out(TimePicker::create(0, 0));
            let (hit, _) = wired_to(&styled, on_hour_up as usize);
            let state = wrapper(TimePickerState {
                hour: start_hour,
                minute: 0,
                is_pm: false,
                is_24h: true,
            });
            let (_, changes) = with_info(styled, hit, |info| {
                adjust_spinner(state.clone(), *info, true, delta)
            });
            assert_eq!(
                read_state(&state).hour,
                want,
                "hour {start_hour} with delta {delta} did not clamp to {want}",
            );
            assert_eq!(
                only_retext(&changes).1,
                want.to_string(),
                "the clamped value and the retext disagree",
            );
        }
    }
    #[test]
    fn adjust_spinner_with_a_zero_delta_still_retexts_and_notifies() {
        // A no-op press is still a press: the display is re-synced with the state
        // and the host is told. (This is the same path a clamped press takes.)
        let probe = log_refany();
        let (styled, shared) = laid_out(
            TimePicker::create(6, 7)
                .with_on_change(probe.clone(), record_change as TimePickerOnChangeCallbackType),
        );
        let (hit, _) = wired_to(&styled, on_hour_up as usize);
        let (update, changes) = with_info(styled, hit, |info| {
            adjust_spinner(shared.clone(), *info, false, 0)
        });
        assert_eq!(read_state(&shared).minute, 7, "a zero delta moved the value");
        assert_eq!(only_retext(&changes).1, "07", "a zero delta skipped the re-sync");
        assert_eq!(update, Update::RefreshDom, "the host's verdict was swallowed");
        assert_eq!(read_log(&probe).seen.len(), 1, "a zero delta did not notify the host");
    }
    #[test]
    fn adjust_spinner_retexts_the_display_of_the_spinner_that_was_clicked() {
        // Both arrows of a column must resolve to the *same* middle display, and
        // never to the other column's.
        for (handler, want_node) in [
            (on_hour_up as usize, N_HOUR_DISPLAY),
            (on_hour_down as usize, N_HOUR_DISPLAY),
            (on_minute_up as usize, N_MINUTE_DISPLAY),
            (on_minute_down as usize, N_MINUTE_DISPLAY),
        ] {
            let (styled, _) = laid_out(TimePicker::create(6, 30));
            let (hit, payload) = wired_to(&styled, handler);
            let is_hour = want_node == N_HOUR_DISPLAY;
            let (_, changes) = with_info(styled, hit, |info| {
                adjust_spinner(payload.clone(), *info, is_hour, 1)
            });
            assert_eq!(
                only_retext(&changes).0,
                node(text_leaf(want_node)),
                "a press on node {hit:?} retexted the wrong display",
            );
        }
    }
    // ==================================================================
    // on_change notification
    // ==================================================================
    #[test]
    fn the_change_callback_sees_the_state_after_the_edit_and_its_verdict_is_forwarded() {
        // Order matters: the state is written *before* the user callback runs, so
        // the callback observes the value the user just asked for, not the stale
        // one it is replacing.
        let probe = log_refany();
        let (styled, shared) = laid_out(
            TimePicker::create(9, 30)
                .with_on_change(probe.clone(), record_change as TimePickerOnChangeCallbackType),
        );
        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
        let (update, changes) = press(styled, &payload, hit, on_hour_up);
        let log = read_log(&probe);
        assert_eq!(
            log.seen,
            vec![TimePickerState {
                hour: 10,
                minute: 30,
                is_pm: false,
                is_24h: true,
            }],
            "the change callback was not called exactly once with the NEW state",
        );
        assert_eq!(
            log.payload, 0xDEAD_BEEF,
            "the callback was handed something other than the user's own RefAny",
        );
        assert_eq!(update, Update::RefreshDom, "the user callback's Update was swallowed");
        assert_eq!(read_state(&shared).hour, 10);
        assert_eq!(changes.len(), 1, "the retext was skipped because a callback ran");
    }
    #[test]
    fn the_change_callback_still_fires_when_the_press_was_clamped_away() {
        // Pinned as-is: a press at the edge of the band reports an unchanged
        // state rather than staying silent. Hosts that treat every notification
        // as an edit will see repeats while an arrow is held down.
        let probe = log_refany();
        let (styled, _) = laid_out(
            TimePicker::create(23, 0)
                .with_on_change(probe.clone(), record_change as TimePickerOnChangeCallbackType),
        );
        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
        press_n(styled, &payload, hit, on_hour_up, 3);
        let log = read_log(&probe);
        assert_eq!(log.seen.len(), 3, "a clamped press stopped notifying the host");
        assert!(
            log.seen.iter().all(|s| s.hour == 23),
            "a clamped press reported a value the widget does not hold",
        );
    }
    #[test]
    fn a_declining_change_callback_does_not_suppress_the_retext() {
        let (styled, shared) = laid_out(
            TimePicker::create(9, 30)
                .with_on_change(RefAny::new(0u8), change_do_nothing as TimePickerOnChangeCallbackType),
        );
        let (hit, payload) = wired_to(&styled, on_minute_up as usize);
        let (update, changes) = press(styled, &payload, hit, on_minute_up);
        assert_eq!(update, Update::DoNothing);
        assert_eq!(read_state(&shared).minute, 31);
        assert_eq!(
            only_retext(&changes),
            (node(text_leaf(N_MINUTE_DISPLAY)), "31".to_string()),
            "a DoNothing user callback suppressed the widget's own repaint",
        );
    }
    #[test]
    fn every_verdict_the_change_callback_returns_is_forwarded_unchanged() {
        for (cb, want) in [
            (change_do_nothing as TimePickerOnChangeCallbackType, Update::DoNothing),
            (change_refresh_all as TimePickerOnChangeCallbackType, Update::RefreshDomAllWindows),
            (record_change as TimePickerOnChangeCallbackType, Update::RefreshDom),
        ] {
            let (styled, _) = laid_out(
                TimePicker::create(9, 30).with_on_change(log_refany(), cb),
            );
            let (hit, payload) = wired_to(&styled, on_hour_down as usize);
            let (update, _) = press(styled, &payload, hit, on_hour_down);
            assert_eq!(update, want, "a user verdict was rewritten on its way out");
        }
    }
    /// A payload that tries to reach back into the widget's own state from
    /// inside the change callback — the re-entrancy the borrow guard must refuse.
    #[derive(Debug)]
    struct ReentrantProbe {
        state: RefAny,
        got_mut: Option<bool>,
        got_ref: Option<bool>,
    }
    extern "C" fn probe_reentrancy(
        mut data: RefAny,
        _info: CallbackInfo,
        _state: TimePickerState,
    ) -> Update {
        if let Some(mut p) = data.downcast_mut::<ReentrantProbe>() {
            let mut s = p.state.clone();
            let got_mut = s.downcast_mut::<TimePickerStateWrapper>().is_some();
            let got_ref = s.downcast_ref::<TimePickerStateWrapper>().is_some();
            p.got_mut = Some(got_mut);
            p.got_ref = Some(got_ref);
        }
        Update::DoNothing
    }
    #[test]
    fn a_reentrant_callback_is_refused_the_state_instead_of_aliasing_it() {
        // The handler holds an exclusive borrow of the state while the user
        // callback runs. A callback that clones the state handle and tries to
        // borrow it again must be told "no" (None), not handed a second `&mut`
        // to memory the handler is still writing through.
        let (styled, shared) = laid_out(TimePicker::create(9, 30));
        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
        let probe = RefAny::new(ReentrantProbe {
            state: shared.clone(),
            got_mut: None,
            got_ref: None,
        });
        {
            let mut s = shared.clone();
            let mut w = s
                .downcast_mut::<TimePickerStateWrapper>()
                .expect("the state changed type");
            w.on_change = Some(TimePickerOnChange {
                callback: TimePickerOnChangeCallback::from(
                    probe_reentrancy as TimePickerOnChangeCallbackType,
                ),
                refany: probe.clone(),
            })
            .into();
        }
        let (update, _) = press(styled, &payload, hit, on_hour_up);
        assert_eq!(update, Update::DoNothing);
        {
            let mut p = probe.clone();
            let seen = p
                .downcast_ref::<ReentrantProbe>()
                .expect("the probe changed type");
            assert_eq!(
                seen.got_mut,
                Some(false),
                "a re-entrant downcast_mut handed out a second &mut to the live state",
            );
            assert_eq!(
                seen.got_ref,
                Some(false),
                "a re-entrant downcast_ref aliased a live &mut borrow",
            );
        }
        assert_eq!(read_state(&shared).hour, 10, "the edit itself was lost");
        // Break the deliberate state -> probe -> state cycle so nothing leaks.
        {
            let mut s = shared.clone();
            if let Some(mut w) = s.downcast_mut::<TimePickerStateWrapper>() {
                w.on_change = None.into();
            };
        }
    }
    // ==================================================================
    // on_ampm_toggle
    // ==================================================================
    #[test]
    fn the_ampm_toggle_flips_the_flag_and_retexts_the_node_that_was_hit() {
        let (styled, shared) = laid_out(TimePicker::create(9, 30).with_24h(false));
        let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
        assert_eq!(hit, node(N_AMPM));
        let (update, changes) = press(styled, &payload, hit, on_ampm_toggle);
        assert!(read_state(&shared).is_pm, "the toggle did not flip AM -> PM");
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            only_retext(&changes),
            (node(text_leaf(N_AMPM)), "PM".to_string()),
            "the toggle did not relabel the text inside its <p>",
        );
    }
    #[test]
    fn the_ampm_toggle_is_an_involution() {
        let (styled, shared) = laid_out(TimePicker::create(9, 30).with_24h(false).with_pm(true));
        let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
        let (_, changes) = press_n(styled, &payload, hit, on_ampm_toggle, 4);
        assert!(read_state(&shared).is_pm, "four flips did not return to PM");
        let labels: Vec<String> = pushed_texts(&changes).into_iter().map(|(_, t)| t).collect();
        assert_eq!(
            labels,
            vec!["AM", "PM", "AM", "PM"],
            "the labels did not alternate with the flag",
        );
    }
    #[test]
    fn the_ampm_toggle_moves_the_canonical_hour_by_exactly_twelve() {
        for hour in 1..=12u32 {
            let (styled, shared) = laid_out(TimePicker::create(hour, 0).with_24h(false));
            let before = read_state(&shared).canonical_hour();
            let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
            press(styled, &payload, hit, on_ampm_toggle);
            let after = read_state(&shared).canonical_hour();
            assert_eq!(
                after,
                before + 12,
                "toggling {hour} AM did not move the canonical hour by twelve",
            );
            assert!(after < 24, "the toggle pushed the canonical hour out of the day");
        }
    }
    #[test]
    fn the_ampm_toggle_leaves_the_hour_and_the_minute_alone() {
        let (styled, shared) = laid_out(TimePicker::create(11, 45).with_24h(false));
        let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
        press(styled, &payload, hit, on_ampm_toggle);
        let s = read_state(&shared);
        assert_eq!((s.hour, s.minute), (11, 45), "the toggle moved the time itself");
        assert!(!s.is_24h, "the toggle changed the display mode");
    }
    #[test]
    fn the_ampm_toggle_notifies_the_host_with_the_new_flag() {
        let probe = log_refany();
        let (styled, _) = laid_out(
            TimePicker::create(3, 15)
                .with_24h(false)
                .with_on_change(probe.clone(), record_change as TimePickerOnChangeCallbackType),
        );
        let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
        let (update, _) = press(styled, &payload, hit, on_ampm_toggle);
        assert_eq!(
            read_log(&probe).seen,
            vec![TimePickerState {
                hour: 3,
                minute: 15,
                is_pm: true,
                is_24h: false,
            }],
            "the host was not told the new AM/PM state",
        );
        assert_eq!(update, Update::RefreshDom, "the host's verdict was swallowed");
    }
    #[test]
    fn the_ampm_toggle_is_declined_for_a_foreign_payload() {
        for payload in [RefAny::new(0u8), RefAny::new(TimePickerState::default())] {
            let (styled, shared) = laid_out(TimePicker::create(9, 30).with_24h(false));
            let (hit, _) = wired_to(&styled, on_ampm_toggle as usize);
            let (update, changes) = press(styled, &payload, hit, on_ampm_toggle);
            assert_eq!(update, Update::DoNothing, "a foreign payload was accepted");
            assert!(changes.is_empty(), "a foreign payload still relabelled the toggle");
            assert!(!read_state(&shared).is_pm, "a foreign payload flipped the real flag");
        }
    }
    #[test]
    fn the_ampm_toggle_flips_the_flag_even_in_24_hour_mode() {
        // The toggle node is never rendered in 24-hour mode, but the handler is
        // still reachable (an FFI host can wire it anywhere). It flips the flag
        // unconditionally — harmless, because `canonical_hour` ignores the flag
        // in 24-hour mode.
        let (styled, shared) = laid_out(TimePicker::create(15, 0));
        let (hit, payload) = wired_to(&styled, on_hour_up as usize);
        let before = read_state(&shared).canonical_hour();
        press(styled, &payload, hit, on_ampm_toggle);
        assert!(read_state(&shared).is_pm, "the flag did not flip");
        assert_eq!(
            read_state(&shared).canonical_hour(),
            before,
            "a 24-hour widget's reading moved when the meaningless flag flipped",
        );
    }
    #[test]
    fn the_ampm_toggle_declines_a_hit_that_wraps_no_text_node() {
        // The toggle relabels the bare text leaf inside its own `<p>`, so a hit
        // it cannot resolve to one (a detached id, or any childless node) is
        // declined *before* the flag flips — no half-applied toggle.
        let (styled, shared) = laid_out(TimePicker::create(9, 30).with_24h(false));
        let (_, payload) = wired_to(&styled, on_ampm_toggle as usize);
        for hit in [node_none(), node(text_leaf(N_AMPM))] {
            let (update, changes) = press(styled.clone(), &payload, hit, on_ampm_toggle);
            assert_eq!(update, Update::DoNothing, "{hit:?}: unexpected verdict");
            assert!(changes.is_empty(), "{hit:?}: an unresolvable hit still retexted");
            assert!(
                !read_state(&shared).is_pm,
                "{hit:?}: an unresolvable hit flipped the flag anyway",
            );
        }
    }
    #[test]
    fn the_rendered_ampm_label_matches_what_the_toggle_would_push() {
        // End-to-end consistency: re-rendering after a toggle must produce the
        // same label the toggle pushed, or the widget would flicker back on the
        // next relayout.
        for start_pm in [false, true] {
            let picker = TimePicker::create(9, 30).with_24h(false).with_pm(start_pm);
            let (styled, shared) = laid_out(picker);
            let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
            let (_, changes) = press(styled, &payload, hit, on_ampm_toggle);
            let pushed = only_retext(&changes).1;
            let after = read_state(&shared);
            let rerendered = ampm_label(
                &TimePicker::create(after.hour, after.minute)
                    .with_24h(false)
                    .with_pm(after.is_pm)
                    .dom(),
            );
            assert_eq!(
                rerendered.as_deref(),
                Some(pushed.as_str()),
                "the pushed label and the re-rendered label disagree (start_pm={start_pm})",
            );
        }
    }
}