1use azul_core::{
24 callbacks::{CoreCallback, CoreCallbackData, Update},
25 dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
26 refany::{OptionRefAny, RefAny},
27};
28use azul_css::dynamic_selector::CssPropertyWithConditions;
29use azul_css::dynamic_selector::CssPropertyWithConditionsVec;
30use azul_css::{
31 props::{
32 basic::{color::ColorU, StyleFontSize},
33 layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignItems, LayoutAlignSelf, LayoutFlexGrow, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight, LayoutWidth, LayoutMarginLeft},
34 property::{CssProperty, *},
35 style::{StyleBackgroundContent, StyleBackgroundContentVec, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextAlign, StyleCursor, StyleUserSelect, StyleTextColor},
36 },
37 impl_option_inner, AzString,
38};
39
40use crate::callbacks::{Callback, CallbackInfo};
41
42static TIME_PICKER_CLASS: &[IdOrClass] =
44 &[Class(AzString::from_const_str("__azul-native-time-picker"))];
45static SPINNER_CLASS: &[IdOrClass] =
46 &[Class(AzString::from_const_str("__azul-native-time-picker-spinner"))];
47static DISPLAY_CLASS: &[IdOrClass] =
48 &[Class(AzString::from_const_str("__azul-native-time-picker-display"))];
49static ARROW_CLASS: &[IdOrClass] =
50 &[Class(AzString::from_const_str("__azul-native-time-picker-arrow"))];
51static SEPARATOR_CLASS: &[IdOrClass] =
52 &[Class(AzString::from_const_str("__azul-native-time-picker-separator"))];
53static AMPM_CLASS: &[IdOrClass] =
54 &[Class(AzString::from_const_str("__azul-native-time-picker-ampm"))];
55
56const UP_ARROW: AzString = AzString::from_const_str("\u{25B2}"); const DOWN_ARROW: AzString = AzString::from_const_str("\u{25BC}"); const SEPARATOR_TEXT: AzString = AzString::from_const_str(":");
59
60pub type TimePickerOnChangeCallbackType =
62 extern "C" fn(RefAny, CallbackInfo, TimePickerState) -> Update;
63impl_widget_callback!(
64 TimePickerOnChange,
65 OptionTimePickerOnChange,
66 TimePickerOnChangeCallback,
67 TimePickerOnChangeCallbackType
68);
69
70azul_core::impl_managed_callback! {
71 wrapper: TimePickerOnChangeCallback,
72 info_ty: CallbackInfo,
73 return_ty: Update,
74 default_ret: Update::DoNothing,
75 invoker_static: TIME_PICKER_ON_CHANGE_INVOKER,
76 invoker_ty: AzTimePickerOnChangeCallbackInvoker,
77 thunk_fn: az_time_picker_on_change_callback_thunk,
78 setter_fn: AzApp_setTimePickerOnChangeCallbackInvoker,
79 from_handle_fn: AzTimePickerOnChangeCallback_createFromHostHandle,
80 extra_args: [ state: TimePickerState ],
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
86#[repr(C)]
87pub struct TimePicker {
88 pub state: TimePickerStateWrapper,
89 pub container_style: CssPropertyWithConditionsVec,
91}
92
93#[derive(Debug, Default, Clone, PartialEq, Eq)]
95#[repr(C)]
96pub struct TimePickerStateWrapper {
97 pub inner: TimePickerState,
98 pub on_change: OptionTimePickerOnChange,
99}
100
101#[derive(Debug, Copy, Clone, PartialEq, Eq)]
103#[repr(C)]
104pub struct TimePickerState {
105 pub hour: u32,
107 pub minute: u32,
109 pub is_pm: bool,
111 pub is_24h: bool,
113}
114
115impl Default for TimePickerState {
116 fn default() -> Self {
117 Self {
118 hour: 0,
119 minute: 0,
120 is_pm: false,
121 is_24h: true,
122 }
123 }
124}
125
126impl TimePickerState {
127 #[must_use] pub const fn canonical_hour(&self) -> u32 {
130 if self.is_24h {
131 self.hour
132 } else {
133 let h12 = self.hour % 12; h12 + if self.is_pm { 12 } else { 0 }
135 }
136 }
137
138 #[inline]
139 const fn hour_bounds(&self) -> (i64, i64) {
140 if self.is_24h {
141 (0, 23)
142 } else {
143 (1, 12)
144 }
145 }
146}
147
148const BORDER_COLOR: ColorU = ColorU { r: 206, g: 212, b: 218, a: 255 };
150const ARROW_COLOR: ColorU = ColorU { r: 73, g: 80, b: 87, a: 255 };
151const TEXT_COLOR: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
152const ACCENT_BG: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
153const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
154
155const ACCENT_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(ACCENT_BG)];
156const ACCENT_BG_VEC: StyleBackgroundContentVec =
157 StyleBackgroundContentVec::from_const_slice(ACCENT_BG_ITEMS);
158
159static 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
230static 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
239static 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
254static 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
268static 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
283static AMPM_STYLE: &[CssPropertyWithConditions] = &[
285 CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
286 CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
287 CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
288 CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
289 CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor { inner: WHITE })),
290 CssPropertyWithConditions::simple(CssProperty::const_background_content(ACCENT_BG_VEC)),
291 CssPropertyWithConditions::simple(CssProperty::const_margin_left(LayoutMarginLeft::const_px(8))),
292 CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(4))),
293 CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
294 LayoutPaddingBottom::const_px(4),
295 )),
296 CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
297 8,
298 ))),
299 CssPropertyWithConditions::simple(CssProperty::const_padding_right(
300 LayoutPaddingRight::const_px(8),
301 )),
302 CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
303 StyleBorderTopLeftRadius::const_px(4),
304 )),
305 CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
306 StyleBorderTopRightRadius::const_px(4),
307 )),
308 CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
309 StyleBorderBottomLeftRadius::const_px(4),
310 )),
311 CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
312 StyleBorderBottomRightRadius::const_px(4),
313 )),
314];
315
316impl TimePicker {
317 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] #[must_use] pub fn create(hour: u32, minute: u32) -> Self {
321 let mut inner = TimePickerState::default();
322 let (lo, hi) = inner.hour_bounds();
323 inner.hour = i64::from(hour).clamp(lo, hi) as u32;
324 inner.minute = i64::from(minute).clamp(0, 59) as u32;
325 Self {
326 state: TimePickerStateWrapper {
327 inner,
328 on_change: None.into(),
329 },
330 container_style: CssPropertyWithConditionsVec::from_const_slice(CONTAINER_STYLE),
331 }
332 }
333
334 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] pub fn set_24h(&mut self, is_24h: bool) {
338 self.state.inner.is_24h = is_24h;
339 let (lo, hi) = self.state.inner.hour_bounds();
340 self.state.inner.hour = i64::from(self.state.inner.hour).clamp(lo, hi) as u32;
341 }
342
343 #[must_use] pub fn with_24h(mut self, is_24h: bool) -> Self {
345 self.set_24h(is_24h);
346 self
347 }
348
349 pub const fn set_pm(&mut self, is_pm: bool) {
351 self.state.inner.is_pm = is_pm;
352 }
353
354 #[must_use] pub const fn with_pm(mut self, is_pm: bool) -> Self {
356 self.set_pm(is_pm);
357 self
358 }
359
360 pub fn set_on_change<C: Into<TimePickerOnChangeCallback>>(&mut self, data: RefAny, callback: C) {
362 self.state.on_change = Some(TimePickerOnChange {
363 callback: callback.into(),
364 refany: data,
365 })
366 .into();
367 }
368
369 #[must_use] pub fn with_on_change<C: Into<TimePickerOnChangeCallback>>(
371 mut self,
372 data: RefAny,
373 callback: C,
374 ) -> Self {
375 self.set_on_change(data, callback);
376 self
377 }
378
379 #[must_use] pub fn swap_with_default(&mut self) -> Self {
381 let mut s = Self::create(0, 0);
382 core::mem::swap(&mut s, self);
383 s
384 }
385
386 #[must_use] pub fn dom(self) -> Dom {
387 let inner = self.state.inner;
388 let is_24h = inner.is_24h;
389 let hour_text = AzString::from(format!("{}", inner.hour));
390 let minute_text = AzString::from(format!("{:02}", inner.minute));
391 let container_style = self.container_style.clone();
392
393 let state = RefAny::new(self.state);
394
395 let mut children = alloc::vec![
396 build_spinner(
397 hour_text,
398 state.clone(),
399 on_hour_up as usize,
400 on_hour_down as usize,
401 ),
402 Dom::create_text(SEPARATOR_TEXT)
403 .with_ids_and_classes(IdOrClassVec::from_const_slice(SEPARATOR_CLASS))
404 .with_css_props(CssPropertyWithConditionsVec::from_const_slice(SEPARATOR_STYLE)),
405 build_spinner(
406 minute_text,
407 state.clone(),
408 on_minute_up as usize,
409 on_minute_down as usize,
410 ),
411 ];
412
413 if !is_24h {
414 let ampm_text = if inner.is_pm {
415 AzString::from_const_str("PM")
416 } else {
417 AzString::from_const_str("AM")
418 };
419 children.push(
420 Dom::create_text(ampm_text)
421 .with_ids_and_classes(IdOrClassVec::from_const_slice(AMPM_CLASS))
422 .with_css_props(CssPropertyWithConditionsVec::from_const_slice(AMPM_STYLE))
423 .with_callbacks(
424 alloc::vec![CoreCallbackData {
425 event: azul_core::dom::EventFilter::Hover(
426 azul_core::dom::HoverEventFilter::MouseUp,
427 ),
428 callback: CoreCallback {
429 cb: on_ampm_toggle as usize,
430 ctx: OptionRefAny::None,
431 },
432 refany: state,
433 }]
434 .into(),
435 )
436 .with_tab_index(TabIndex::Auto),
437 );
438 }
439
440 Dom::create_div()
441 .with_ids_and_classes(IdOrClassVec::from_const_slice(TIME_PICKER_CLASS))
442 .with_css_props(container_style)
443 .with_children(children.into())
444 }
445}
446
447impl Default for TimePicker {
448 fn default() -> Self {
449 Self::create(0, 0)
450 }
451}
452
453fn build_spinner(value: AzString, state: RefAny, up_cb: usize, down_cb: usize) -> Dom {
457 use azul_core::dom::{EventFilter, HoverEventFilter};
458
459 let arrow_cell = |arrow: AzString, cb: usize, refany: RefAny| -> Dom {
460 Dom::create_text(arrow)
461 .with_ids_and_classes(IdOrClassVec::from_const_slice(ARROW_CLASS))
462 .with_css_props(CssPropertyWithConditionsVec::from_const_slice(ARROW_STYLE))
463 .with_callbacks(
464 alloc::vec![CoreCallbackData {
465 event: EventFilter::Hover(HoverEventFilter::MouseUp),
466 callback: CoreCallback {
467 cb,
468 ctx: OptionRefAny::None,
469 },
470 refany,
471 }]
472 .into(),
473 )
474 .with_tab_index(TabIndex::Auto)
475 };
476
477 Dom::create_div()
478 .with_ids_and_classes(IdOrClassVec::from_const_slice(SPINNER_CLASS))
479 .with_css_props(CssPropertyWithConditionsVec::from_const_slice(SPINNER_STYLE))
480 .with_children(
481 alloc::vec![
482 arrow_cell(UP_ARROW, up_cb, state.clone()),
483 Dom::create_text(value)
484 .with_ids_and_classes(IdOrClassVec::from_const_slice(DISPLAY_CLASS))
485 .with_css_props(CssPropertyWithConditionsVec::from_const_slice(DISPLAY_STYLE)),
486 arrow_cell(DOWN_ARROW, down_cb, state),
487 ]
488 .into(),
489 )
490}
491
492#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] fn adjust_spinner(mut data: RefAny, mut info: CallbackInfo, is_hour: bool, delta: i64) -> Update {
497 let hit = info.get_hit_node();
500 let Some(parent) = info.get_parent(hit) else {
501 return Update::DoNothing;
502 };
503 let Some(up) = info.get_first_child(parent) else {
504 return Update::DoNothing;
505 };
506 let Some(display) = info.get_next_sibling(up) else {
507 return Update::DoNothing;
508 };
509
510 let (update, display_text) = {
511 let Some(mut w) = data.downcast_mut::<TimePickerStateWrapper>() else {
512 return Update::DoNothing;
513 };
514
515 let display_text = if is_hour {
516 let (lo, hi) = w.inner.hour_bounds();
517 w.inner.hour = (i64::from(w.inner.hour) + delta).clamp(lo, hi) as u32;
518 AzString::from(format!("{}", w.inner.hour))
519 } else {
520 w.inner.minute = (i64::from(w.inner.minute) + delta).clamp(0, 59) as u32;
522 AzString::from(format!("{:02}", w.inner.minute))
523 };
524
525 let inner = w.inner;
526 let w = &mut *w;
527 let update = match w.on_change.as_mut() {
528 Some(TimePickerOnChange { callback, refany }) => {
529 (callback.cb)(refany.clone(), info, inner)
530 }
531 None => Update::DoNothing,
532 };
533 (update, display_text)
534 };
535
536 info.change_node_text(display, display_text);
537 update
538}
539
540extern "C" fn on_hour_up(data: RefAny, info: CallbackInfo) -> Update {
541 adjust_spinner(data, info, true, 1)
542}
543
544extern "C" fn on_hour_down(data: RefAny, info: CallbackInfo) -> Update {
545 adjust_spinner(data, info, true, -1)
546}
547
548extern "C" fn on_minute_up(data: RefAny, info: CallbackInfo) -> Update {
549 adjust_spinner(data, info, false, 1)
550}
551
552extern "C" fn on_minute_down(data: RefAny, info: CallbackInfo) -> Update {
553 adjust_spinner(data, info, false, -1)
554}
555
556extern "C" fn on_ampm_toggle(mut data: RefAny, mut info: CallbackInfo) -> Update {
558 let hit = info.get_hit_node();
559
560 let (update, text) = {
561 let Some(mut w) = data.downcast_mut::<TimePickerStateWrapper>() else {
562 return Update::DoNothing;
563 };
564 w.inner.is_pm = !w.inner.is_pm;
565 let inner = w.inner;
566 let text = if inner.is_pm {
567 AzString::from_const_str("PM")
568 } else {
569 AzString::from_const_str("AM")
570 };
571 let w = &mut *w;
572 let update = match w.on_change.as_mut() {
573 Some(TimePickerOnChange { callback, refany }) => {
574 (callback.cb)(refany.clone(), info, inner)
575 }
576 None => Update::DoNothing,
577 };
578 (update, text)
579 };
580
581 info.change_node_text(hit, text);
582 update
583}
584
585impl From<TimePicker> for Dom {
586 fn from(t: TimePicker) -> Self {
587 t.dom()
588 }
589}
590
591#[cfg(test)]
592mod autotest_generated {
593 use std::{
594 collections::{BTreeMap, HashMap},
595 sync::{Arc, Mutex},
596 };
597
598 use azul_core::{
599 dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
600 geom::{LogicalRect, OptionLogicalPosition},
601 gl::OptionGlContextPtr,
602 hit_test::ScrollPosition,
603 resources::RendererResources,
604 styled_dom::{NodeHierarchyItemId, StyledDom},
605 window::{MonitorVec, RawWindowHandle},
606 };
607 use rust_fontconfig::FcFontCache;
608
609 use super::*;
610 #[cfg(feature = "icu")]
611 use crate::icu::IcuLocalizerHandle;
612 use crate::{
613 callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
614 solver3::{display_list::DisplayList, layout_tree::LayoutTree},
615 window::{DomLayoutResult, LayoutWindow},
616 window_state::FullWindowState,
617 };
618
619 const EXTREME_24H: TimePickerState = TimePickerState {
629 hour: u32::MAX,
630 minute: u32::MAX,
631 is_pm: true,
632 is_24h: true,
633 };
634 const EXTREME_12H: TimePickerState = TimePickerState {
635 hour: u32::MAX,
636 minute: u32::MAX,
637 is_pm: true,
638 is_24h: false,
639 };
640
641 const _CANONICAL_AT_MAX_24H: u32 = EXTREME_24H.canonical_hour();
642 const _CANONICAL_AT_MAX_12H: u32 = EXTREME_12H.canonical_hour();
643 const _BOUNDS_AT_MAX_24H: (i64, i64) = EXTREME_24H.hour_bounds();
644 const _BOUNDS_AT_MAX_12H: (i64, i64) = EXTREME_12H.hour_bounds();
645
646 const N_CONTAINER: usize = 0;
664 const N_HOUR_SPINNER: usize = 1;
665 const N_HOUR_UP: usize = 2;
666 const N_HOUR_DISPLAY: usize = 3;
667 const N_HOUR_DOWN: usize = 4;
668 const N_SEPARATOR: usize = 5;
669 const N_MINUTE_SPINNER: usize = 6;
670 const N_MINUTE_UP: usize = 7;
671 const N_MINUTE_DISPLAY: usize = 8;
672 const N_MINUTE_DOWN: usize = 9;
673 const N_AMPM: usize = 10;
674
675 const CLASS_CONTAINER: &str = "__azul-native-time-picker";
679 const CLASS_SPINNER: &str = "__azul-native-time-picker-spinner";
680 const CLASS_DISPLAY: &str = "__azul-native-time-picker-display";
681 const CLASS_ARROW: &str = "__azul-native-time-picker-arrow";
682 const CLASS_SEPARATOR: &str = "__azul-native-time-picker-separator";
683 const CLASS_AMPM: &str = "__azul-native-time-picker-ampm";
684
685 const UP_GLYPH: &str = "\u{25B2}";
686 const DOWN_GLYPH: &str = "\u{25BC}";
687
688 fn node(idx: usize) -> DomNodeId {
694 DomNodeId {
695 dom: DomId::ROOT_ID,
696 node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
697 }
698 }
699
700 fn node_none() -> DomNodeId {
703 DomNodeId {
704 dom: DomId::ROOT_ID,
705 node: NodeHierarchyItemId::NONE,
706 }
707 }
708
709 fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
714 DomLayoutResult {
715 styled_dom,
716 layout_tree: LayoutTree {
717 nodes: Vec::new(),
718 warm: Vec::new(),
719 cold: Vec::new(),
720 root: 0,
721 dom_to_layout: BTreeMap::new(),
722 children_arena: Vec::new(),
723 children_offsets: Vec::new(),
724 subtree_needs_intrinsic: Vec::new(),
725 },
726 calculated_positions: Vec::new(),
727 viewport: LogicalRect::zero(),
728 display_list: DisplayList::default(),
729 scroll_ids: HashMap::new(),
730 scroll_id_to_node_id: HashMap::new(),
731 }
732 }
733
734 fn with_info<R>(
738 styled_dom: StyledDom,
739 hit: DomNodeId,
740 f: impl FnOnce(&mut CallbackInfo) -> R,
741 ) -> (R, Vec<CallbackChange>) {
742 let mut layout_window =
743 LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
744 layout_window
745 .layout_results
746 .insert(DomId::ROOT_ID, layout_result(styled_dom));
747
748 let renderer_resources = RendererResources::default();
749 let previous_window_state: Option<FullWindowState> = None;
750 let current_window_state = FullWindowState::default();
751 let gl_context = OptionGlContextPtr::None;
752 let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
753 BTreeMap::new();
754 let window_handle = RawWindowHandle::Unsupported;
755 let system_callbacks = ExternalSystemCallbacks::rust_internal();
756
757 let ref_data = CallbackInfoRefData {
758 layout_window: &layout_window,
759 renderer_resources: &renderer_resources,
760 previous_window_state: &previous_window_state,
761 current_window_state: ¤t_window_state,
762 gl_context: &gl_context,
763 current_scroll_manager: &scroll_states,
764 current_window_handle: &window_handle,
765 system_callbacks: &system_callbacks,
766 system_style: Arc::new(azul_css::system::SystemStyle::default()),
767 monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
768 #[cfg(feature = "icu")]
769 icu_localizer: IcuLocalizerHandle::default(),
770 ctx: OptionRefAny::None,
771 };
772
773 let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
774
775 let mut info = CallbackInfo::new(
776 &ref_data,
777 &changes,
778 hit,
779 OptionLogicalPosition::None,
780 OptionLogicalPosition::None,
781 );
782
783 let r = f(&mut info);
784 let pushed = info.take_changes();
785 (r, pushed)
786 }
787
788 fn text_of(dom: &Dom) -> Option<String> {
794 dom.root.get_node_type().format()
795 }
796
797 fn classes(dom: &Dom) -> Vec<String> {
798 dom.root
799 .get_ids_and_classes()
800 .as_ref()
801 .iter()
802 .filter_map(|c| match c {
803 IdOrClass::Class(s) => Some(s.as_str().to_string()),
804 IdOrClass::Id(_) => None,
805 })
806 .collect()
807 }
808
809 fn flat_classes(sd: &StyledDom, idx: usize) -> Vec<String> {
811 sd.node_data.as_ref()[idx]
812 .get_ids_and_classes()
813 .as_ref()
814 .iter()
815 .filter_map(|c| match c {
816 IdOrClass::Class(s) => Some(s.as_str().to_string()),
817 IdOrClass::Id(_) => None,
818 })
819 .collect()
820 }
821
822 fn flat_text(sd: &StyledDom, idx: usize) -> Option<String> {
824 sd.node_data.as_ref()[idx].get_node_type().format()
825 }
826
827 fn descendants(dom: &Dom) -> usize {
831 dom.children
832 .as_ref()
833 .iter()
834 .map(|c| 1 + descendants(c))
835 .sum()
836 }
837
838 fn columns(dom: &Dom) -> (&Dom, &Dom, &Dom) {
840 let c = dom.children.as_ref();
841 assert!(
842 c.len() == 3 || c.len() == 4,
843 "a time picker renders hour + ':' + minute (+ AM/PM), got {} children",
844 c.len(),
845 );
846 (&c[0], &c[1], &c[2])
847 }
848
849 fn displayed(dom: &Dom) -> (String, String) {
851 let (hour, _, minute) = columns(dom);
852 (
853 text_of(&hour.children.as_ref()[1]).expect("the hour display is not a text node"),
854 text_of(&minute.children.as_ref()[1]).expect("the minute display is not a text node"),
855 )
856 }
857
858 fn ampm_label(dom: &Dom) -> Option<String> {
860 dom.children.as_ref().get(3).and_then(text_of)
861 }
862
863 fn state_payloads(sd: &StyledDom) -> Vec<RefAny> {
865 let mut out = Vec::new();
866 for nd in sd.node_data.as_ref() {
867 for cb in nd.callbacks.as_ref() {
868 let carries = {
869 let mut r = cb.refany.clone();
870 let carries = r.downcast_ref::<TimePickerStateWrapper>().is_some();
871 carries
872 };
873 if carries {
874 out.push(cb.refany.clone());
875 }
876 }
877 }
878 out
879 }
880
881 fn shared_state(sd: &StyledDom) -> RefAny {
883 state_payloads(sd)
884 .into_iter()
885 .next()
886 .expect("the rendered time picker carries no TimePickerStateWrapper")
887 }
888
889 fn wired_to(sd: &StyledDom, handler: usize) -> (DomNodeId, RefAny) {
891 for (i, nd) in sd.node_data.as_ref().iter().enumerate() {
892 for cb in nd.callbacks.as_ref() {
893 if cb.callback.cb == handler {
894 return (node(i), cb.refany.clone());
895 }
896 }
897 }
898 panic!("the rendered time picker has nothing wired to that handler");
899 }
900
901 fn read_state(shared: &RefAny) -> TimePickerState {
902 let mut s = shared.clone();
903 let w = s
904 .downcast_ref::<TimePickerStateWrapper>()
905 .expect("the widget state changed type");
906 w.inner
907 }
908
909 fn laid_out(picker: TimePicker) -> (StyledDom, RefAny) {
913 let styled = StyledDom::create_from_dom(picker.dom());
914 let shared = shared_state(&styled);
915 (styled, shared)
916 }
917
918 fn wrapper(inner: TimePickerState) -> RefAny {
921 RefAny::new(TimePickerStateWrapper {
922 inner,
923 on_change: None.into(),
924 })
925 }
926
927 fn press(
929 styled_dom: StyledDom,
930 payload: &RefAny,
931 hit: DomNodeId,
932 handler: extern "C" fn(RefAny, CallbackInfo) -> Update,
933 ) -> (Update, Vec<CallbackChange>) {
934 with_info(styled_dom, hit, |info| handler(payload.clone(), *info))
935 }
936
937 fn press_n(
939 styled_dom: StyledDom,
940 payload: &RefAny,
941 hit: DomNodeId,
942 handler: extern "C" fn(RefAny, CallbackInfo) -> Update,
943 times: usize,
944 ) -> (Update, Vec<CallbackChange>) {
945 with_info(styled_dom, hit, |info| {
946 let mut last = Update::DoNothing;
947 for _ in 0..times {
948 last = handler(payload.clone(), *info);
949 }
950 last
951 })
952 }
953
954 fn pushed_texts(changes: &[CallbackChange]) -> Vec<(DomNodeId, String)> {
956 changes
957 .iter()
958 .filter_map(|c| match c {
959 CallbackChange::ChangeNodeText { node_id, text } => {
960 Some((*node_id, text.as_str().to_string()))
961 }
962 _ => None,
963 })
964 .collect()
965 }
966
967 fn only_retext(changes: &[CallbackChange]) -> (DomNodeId, String) {
970 let texts = pushed_texts(changes);
971 assert_eq!(
972 texts.len(),
973 1,
974 "expected exactly one retext, got {} change(s) total",
975 changes.len(),
976 );
977 assert_eq!(
978 changes.len(),
979 1,
980 "the press pushed {} change(s) beyond its retext",
981 changes.len() - 1,
982 );
983 texts.into_iter().next().unwrap()
984 }
985
986 fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
991 v.as_ref().iter().map(|p| p.property.clone()).collect()
992 }
993
994 #[derive(Debug, Clone, PartialEq, Eq)]
1002 struct ChangeLog {
1003 seen: Vec<TimePickerState>,
1004 payload: u32,
1005 }
1006
1007 extern "C" fn record_change(
1008 mut data: RefAny,
1009 _info: CallbackInfo,
1010 state: TimePickerState,
1011 ) -> Update {
1012 if let Some(mut log) = data.downcast_mut::<ChangeLog>() {
1013 log.seen.push(state);
1014 }
1015 Update::RefreshDom
1016 }
1017
1018 extern "C" fn change_do_nothing(
1019 _data: RefAny,
1020 _info: CallbackInfo,
1021 _state: TimePickerState,
1022 ) -> Update {
1023 Update::DoNothing
1024 }
1025
1026 extern "C" fn change_refresh_all(
1027 _data: RefAny,
1028 _info: CallbackInfo,
1029 _state: TimePickerState,
1030 ) -> Update {
1031 Update::RefreshDomAllWindows
1032 }
1033
1034 extern "C" fn generic_shaped(_data: RefAny, _info: CallbackInfo) -> Update {
1038 Update::DoNothing
1039 }
1040
1041 fn log_refany() -> RefAny {
1042 RefAny::new(ChangeLog {
1043 seen: Vec::new(),
1044 payload: 0xDEAD_BEEF,
1045 })
1046 }
1047
1048 fn read_log(probe: &RefAny) -> ChangeLog {
1049 let mut probe = probe.clone();
1050 let log = probe
1051 .downcast_ref::<ChangeLog>()
1052 .expect("the user payload changed type");
1053 log.clone()
1054 }
1055
1056 #[test]
1061 fn canonical_hour_maps_every_twelve_hour_clock_face_to_its_canonical_hour() {
1062 for (hour, is_pm, want) in [
1065 (12u32, false, 0u32),
1066 (1, false, 1),
1067 (11, false, 11),
1068 (12, true, 12),
1069 (1, true, 13),
1070 (11, true, 23),
1071 ] {
1072 let s = TimePickerState {
1073 hour,
1074 minute: 0,
1075 is_pm,
1076 is_24h: false,
1077 };
1078 assert_eq!(
1079 s.canonical_hour(),
1080 want,
1081 "{hour} {} is not canonical hour {want}",
1082 if is_pm { "PM" } else { "AM" },
1083 );
1084 }
1085 }
1086
1087 #[test]
1088 fn canonical_hour_round_trips_every_hour_of_the_day_through_the_clock_face() {
1089 for canonical in 0..24u32 {
1093 let face = if canonical % 12 == 0 { 12 } else { canonical % 12 };
1094 let s = TimePickerState {
1095 hour: face,
1096 minute: 0,
1097 is_pm: canonical >= 12,
1098 is_24h: false,
1099 };
1100 assert_eq!(
1101 s.canonical_hour(),
1102 canonical,
1103 "the 12-hour encoding of {canonical}:00 did not decode back",
1104 );
1105 }
1106 }
1107
1108 #[test]
1109 fn canonical_hour_never_leaves_the_day_in_twelve_hour_mode_for_any_hour() {
1110 for hour in [
1114 0u32,
1115 1,
1116 12,
1117 13,
1118 23,
1119 24,
1120 25,
1121 59,
1122 100,
1123 1_000_000,
1124 u32::MAX / 2,
1125 u32::MAX - 1,
1126 u32::MAX,
1127 ] {
1128 for is_pm in [false, true] {
1129 let s = TimePickerState {
1130 hour,
1131 minute: 0,
1132 is_pm,
1133 is_24h: false,
1134 };
1135 let c = s.canonical_hour();
1136 assert!(
1137 c < 24,
1138 "canonical_hour({hour}, pm={is_pm}) = {c} is not an hour of the day",
1139 );
1140 }
1141 }
1142 }
1143
1144 #[test]
1145 fn canonical_hour_in_24h_mode_returns_the_hour_verbatim() {
1146 for hour in [0u32, 23, 24, 99, u32::MAX] {
1152 let s = TimePickerState {
1153 hour,
1154 minute: 0,
1155 is_pm: false,
1156 is_24h: true,
1157 };
1158 assert_eq!(
1159 s.canonical_hour(),
1160 hour,
1161 "the 24-hour arm normalised {hour}, which it has never done",
1162 );
1163 }
1164 }
1165
1166 #[test]
1167 fn canonical_hour_ignores_the_pm_flag_in_24h_mode() {
1168 for hour in 0..24u32 {
1171 let am = TimePickerState {
1172 hour,
1173 minute: 0,
1174 is_pm: false,
1175 is_24h: true,
1176 };
1177 let pm = TimePickerState { is_pm: true, ..am };
1178 assert_eq!(
1179 am.canonical_hour(),
1180 pm.canonical_hour(),
1181 "the PM flag leaked into the 24-hour reading of hour {hour}",
1182 );
1183 }
1184 }
1185
1186 #[test]
1187 fn canonical_hour_does_not_depend_on_the_minute() {
1188 for minute in [0u32, 30, 59, 60, u32::MAX] {
1189 for is_24h in [false, true] {
1190 let s = TimePickerState {
1191 hour: 7,
1192 minute,
1193 is_pm: true,
1194 is_24h,
1195 };
1196 let baseline = TimePickerState { minute: 0, ..s };
1197 assert_eq!(
1198 s.canonical_hour(),
1199 baseline.canonical_hour(),
1200 "minute {minute} changed the hour reading (is_24h={is_24h})",
1201 );
1202 }
1203 }
1204 }
1205
1206 #[test]
1207 fn canonical_hour_of_a_default_state_is_midnight() {
1208 assert_eq!(TimePickerState::default().canonical_hour(), 0);
1209 assert_eq!(TimePicker::default().state.inner.canonical_hour(), 0);
1210 assert_eq!(TimePickerStateWrapper::default().inner.canonical_hour(), 0);
1211 }
1212
1213 #[test]
1214 fn canonical_hour_is_total_at_the_u32_extremes() {
1215 assert_eq!(EXTREME_24H.canonical_hour(), u32::MAX);
1217 assert_eq!(EXTREME_12H.canonical_hour(), 15);
1219 }
1220
1221 #[test]
1226 fn hour_bounds_are_the_two_documented_bands() {
1227 let mut s = TimePickerState {
1228 is_24h: true,
1229 ..Default::default()
1230 };
1231 assert_eq!(s.hour_bounds(), (0, 23), "the 24-hour band is wrong");
1232 s.is_24h = false;
1233 assert_eq!(s.hour_bounds(), (1, 12), "the 12-hour band is wrong");
1234 }
1235
1236 #[test]
1237 fn hour_bounds_depend_on_nothing_but_the_mode() {
1238 for hour in [0u32, 1, 12, 13, 23, u32::MAX] {
1241 for minute in [0u32, 59, u32::MAX] {
1242 for is_pm in [false, true] {
1243 for is_24h in [false, true] {
1244 let s = TimePickerState {
1245 hour,
1246 minute,
1247 is_pm,
1248 is_24h,
1249 };
1250 let want = if is_24h { (0, 23) } else { (1, 12) };
1251 assert_eq!(
1252 s.hour_bounds(),
1253 want,
1254 "hour_bounds drifted for {hour}:{minute} pm={is_pm} 24h={is_24h}",
1255 );
1256 }
1257 }
1258 }
1259 }
1260 }
1261
1262 #[test]
1263 fn hour_bounds_are_ordered_and_fit_in_a_u32() {
1264 for is_24h in [false, true] {
1268 let s = TimePickerState {
1269 is_24h,
1270 ..TimePickerState::default()
1271 };
1272 let (lo, hi) = s.hour_bounds();
1273 assert!(lo <= hi, "hour_bounds({is_24h}) = ({lo}, {hi}) is inverted");
1274 assert!(lo >= 0, "a negative low bound would cast to a huge u32");
1275 assert!(hi <= i64::from(u32::MAX), "the high bound does not fit a u32");
1276 assert!(hi < 24, "the high bound is not an hour of the day");
1277 }
1278 }
1279
1280 #[test]
1281 fn hour_bounds_agree_with_what_the_constructors_actually_clamp_to() {
1282 let (lo24, hi24) = TimePickerState {
1285 is_24h: true,
1286 ..TimePickerState::default()
1287 }
1288 .hour_bounds();
1289 assert_eq!(u32::try_from(lo24).unwrap(), TimePicker::create(0, 0).state.inner.hour);
1290 assert_eq!(
1291 u32::try_from(hi24).unwrap(),
1292 TimePicker::create(u32::MAX, 0).state.inner.hour,
1293 );
1294
1295 let (lo12, hi12) = TimePickerState {
1296 is_24h: false,
1297 ..TimePickerState::default()
1298 }
1299 .hour_bounds();
1300 assert_eq!(
1301 u32::try_from(lo12).unwrap(),
1302 TimePicker::create(0, 0).with_24h(false).state.inner.hour,
1303 );
1304 assert_eq!(
1305 u32::try_from(hi12).unwrap(),
1306 TimePicker::create(u32::MAX, 0).with_24h(false).state.inner.hour,
1307 );
1308 }
1309
1310 #[test]
1315 fn create_clamps_rather_than_wraps_at_every_extreme() {
1316 for (hour, minute, want_h, want_m) in [
1319 (0u32, 0u32, 0u32, 0u32),
1320 (23, 59, 23, 59),
1321 (24, 60, 23, 59),
1322 (25, 61, 23, 59),
1323 (99, 99, 23, 59),
1324 (u32::MAX - 1, u32::MAX - 1, 23, 59),
1325 (u32::MAX, u32::MAX, 23, 59),
1326 ] {
1327 let p = TimePicker::create(hour, minute);
1328 assert_eq!(
1329 (p.state.inner.hour, p.state.inner.minute),
1330 (want_h, want_m),
1331 "create({hour}, {minute}) was not clamped into range",
1332 );
1333 }
1334 assert_ne!(
1335 TimePicker::create(u32::MAX, u32::MAX).state.inner.hour,
1336 u32::MAX % 24,
1337 "create wrapped the hour instead of clamping it",
1338 );
1339 assert_ne!(
1340 TimePicker::create(u32::MAX, u32::MAX).state.inner.minute,
1341 u32::MAX % 60,
1342 "create wrapped the minute instead of clamping it",
1343 );
1344 }
1345
1346 #[test]
1347 fn create_lands_inside_its_own_bounds_for_every_input_it_is_given() {
1348 for hour in [0u32, 1, 12, 23, 24, 1000, u32::MAX / 3, u32::MAX] {
1349 for minute in [0u32, 1, 30, 59, 60, 12345, u32::MAX] {
1350 let p = TimePicker::create(hour, minute);
1351 let (lo, hi) = p.state.inner.hour_bounds();
1352 let h = i64::from(p.state.inner.hour);
1353 assert!(
1354 (lo..=hi).contains(&h),
1355 "create({hour}, {minute}) left hour {h} outside {lo}..={hi}",
1356 );
1357 assert!(
1358 p.state.inner.minute <= 59,
1359 "create({hour}, {minute}) left minute {} outside 0..=59",
1360 p.state.inner.minute,
1361 );
1362 }
1363 }
1364 }
1365
1366 #[test]
1367 fn create_passes_every_in_range_value_through_untouched() {
1368 for hour in 0..24u32 {
1369 for minute in [0u32, 1, 7, 30, 58, 59] {
1370 let p = TimePicker::create(hour, minute);
1371 assert_eq!(
1372 (p.state.inner.hour, p.state.inner.minute),
1373 (hour, minute),
1374 "create mangled the in-range value {hour}:{minute}",
1375 );
1376 }
1377 }
1378 }
1379
1380 #[test]
1381 fn create_always_starts_in_24h_am_without_a_callback() {
1382 for (hour, minute) in [(0u32, 0u32), (13, 45), (u32::MAX, u32::MAX)] {
1383 let p = TimePicker::create(hour, minute);
1384 assert!(p.state.inner.is_24h, "create({hour}, {minute}) did not start in 24-hour mode");
1385 assert!(!p.state.inner.is_pm, "create({hour}, {minute}) started in PM");
1386 assert!(
1387 p.state.on_change.as_ref().is_none(),
1388 "create({hour}, {minute}) installed a callback nobody asked for",
1389 );
1390 }
1391 }
1392
1393 #[test]
1394 fn create_zero_is_exactly_the_default_picker() {
1395 assert_eq!(TimePicker::create(0, 0), TimePicker::default());
1396 assert_eq!(TimePicker::create(0, 0).state.inner, TimePickerState::default());
1397 assert_eq!(TimePicker::default().state, TimePickerStateWrapper::default());
1398 }
1399
1400 #[test]
1401 fn create_uses_the_shared_const_container_style() {
1402 let p = TimePicker::create(9, 15);
1405 assert_eq!(
1406 properties(&p.container_style),
1407 CONTAINER_STYLE
1408 .iter()
1409 .map(|c| c.property.clone())
1410 .collect::<Vec<_>>(),
1411 "the container style is not the shared const declaration",
1412 );
1413 }
1414
1415 #[test]
1416 fn create_is_deterministic() {
1417 for (h, m) in [(0u32, 0u32), (7, 8), (u32::MAX, u32::MAX)] {
1418 assert_eq!(TimePicker::create(h, m), TimePicker::create(h, m));
1419 }
1420 }
1421
1422 #[test]
1427 fn set_24h_re_clamps_the_hour_into_the_new_band() {
1428 for (hour, want) in [(0u32, 1u32), (1, 1), (11, 11), (12, 12), (13, 12), (23, 12)] {
1429 let mut p = TimePicker::create(hour, 0);
1430 p.set_24h(false);
1431 assert_eq!(
1432 p.state.inner.hour, want,
1433 "switching {hour}:00 to 12-hour mode did not clamp to {want}",
1434 );
1435 }
1436 }
1437
1438 #[test]
1439 fn set_24h_clamps_afternoon_hours_instead_of_converting_them() {
1440 let mut p = TimePicker::create(13, 30);
1446 assert_eq!(p.state.inner.canonical_hour(), 13);
1447 p.set_24h(false);
1448 assert_eq!(p.state.inner.hour, 12, "13:00 was not clamped to 12");
1449 assert!(!p.state.inner.is_pm, "set_24h invented a PM flag");
1450 assert_eq!(
1451 p.state.inner.canonical_hour(),
1452 0,
1453 "the clamp is expected to read back as midnight, not as 13:00",
1454 );
1455 }
1456
1457 #[test]
1458 fn set_24h_is_idempotent() {
1459 for start in [0u32, 5, 13, 23] {
1460 for target in [false, true] {
1461 let mut once = TimePicker::create(start, 30);
1462 once.set_24h(target);
1463 let mut twice = TimePicker::create(start, 30);
1464 twice.set_24h(target);
1465 twice.set_24h(target);
1466 assert_eq!(
1467 once.state.inner, twice.state.inner,
1468 "set_24h({target}) is not idempotent from {start}:30",
1469 );
1470 }
1471 }
1472 }
1473
1474 #[test]
1475 fn set_24h_round_trip_is_the_identity_only_inside_the_narrow_band() {
1476 for hour in 0..24u32 {
1479 let mut p = TimePicker::create(hour, 0);
1480 p.set_24h(false);
1481 p.set_24h(true);
1482 if (1..=12).contains(&hour) {
1483 assert_eq!(p.state.inner.hour, hour, "the round trip lost hour {hour}");
1484 } else {
1485 let want = if hour == 0 { 1 } else { 12 };
1486 assert_eq!(
1487 p.state.inner.hour, want,
1488 "hour {hour} did not collapse onto {want} as the clamp dictates",
1489 );
1490 }
1491 }
1492 }
1493
1494 #[test]
1495 fn set_24h_never_touches_the_minute_the_pm_flag_or_the_style() {
1496 for target in [false, true] {
1497 let before = TimePicker::create(9, 41).with_pm(true);
1498 let mut after = before.clone();
1499 after.set_24h(target);
1500 assert_eq!(after.state.inner.minute, before.state.inner.minute, "the minute moved");
1501 assert_eq!(after.state.inner.is_pm, before.state.inner.is_pm, "the PM flag moved");
1502 assert_eq!(
1503 properties(&after.container_style),
1504 properties(&before.container_style),
1505 "switching modes restyled the container",
1506 );
1507 }
1508 }
1509
1510 #[test]
1511 fn set_24h_leaves_a_hand_written_out_of_range_hour_inside_the_band() {
1512 for hour in [0u32, 24, 100, u32::MAX / 2, u32::MAX - 1, u32::MAX] {
1515 for target in [false, true] {
1516 let mut p = TimePicker::create(0, 0);
1517 p.state.inner.hour = hour;
1518 p.set_24h(target);
1519 let (lo, hi) = p.state.inner.hour_bounds();
1520 let h = i64::from(p.state.inner.hour);
1521 assert!(
1522 (lo..=hi).contains(&h),
1523 "set_24h({target}) left hand-written hour {hour} at {h}, outside {lo}..={hi}",
1524 );
1525 }
1526 }
1527 }
1528
1529 #[test]
1530 fn with_24h_is_exactly_set_24h_in_builder_form() {
1531 for target in [false, true] {
1532 for hour in [0u32, 6, 13, 23] {
1533 let built = TimePicker::create(hour, 12).with_24h(target);
1534 let mut set = TimePicker::create(hour, 12);
1535 set.set_24h(target);
1536 assert_eq!(built, set, "with_24h({target}) diverged from set_24h at {hour}:12");
1537 }
1538 }
1539 }
1540
1541 #[test]
1542 fn with_24h_keeps_the_post_construction_invariants() {
1543 for target in [false, true] {
1544 let p = TimePicker::create(u32::MAX, u32::MAX).with_24h(target);
1545 assert_eq!(p.state.inner.is_24h, target, "the mode flag was not stored");
1546 let (lo, hi) = p.state.inner.hour_bounds();
1547 assert!((lo..=hi).contains(&i64::from(p.state.inner.hour)));
1548 assert!(p.state.inner.minute <= 59);
1549 }
1550 }
1551
1552 #[test]
1557 fn set_pm_moves_nothing_but_the_flag() {
1558 for target in [false, true] {
1559 for is_24h in [false, true] {
1560 let before = TimePicker::create(11, 22).with_24h(is_24h);
1561 let mut after = before.clone();
1562 after.set_pm(target);
1563 assert_eq!(after.state.inner.is_pm, target, "the PM flag was not stored");
1564 assert_eq!(after.state.inner.hour, before.state.inner.hour, "the hour moved");
1565 assert_eq!(after.state.inner.minute, before.state.inner.minute, "the minute moved");
1566 assert_eq!(after.state.inner.is_24h, before.state.inner.is_24h, "the mode moved");
1567 }
1568 }
1569 }
1570
1571 #[test]
1572 fn set_pm_is_idempotent_and_two_flips_are_the_identity() {
1573 let mut p = TimePicker::create(4, 4).with_24h(false);
1574 p.set_pm(true);
1575 let once = p.state.inner;
1576 p.set_pm(true);
1577 assert_eq!(p.state.inner, once, "set_pm(true) is not idempotent");
1578 p.set_pm(false);
1579 p.set_pm(true);
1580 assert_eq!(p.state.inner, once, "two flips did not return to the same state");
1581 }
1582
1583 #[test]
1584 fn set_pm_does_not_re_clamp_an_out_of_band_hour() {
1585 let mut p = TimePicker::create(0, 0);
1588 p.state.inner.hour = u32::MAX;
1589 p.set_pm(true);
1590 assert_eq!(p.state.inner.hour, u32::MAX, "set_pm re-clamped the hour");
1591 }
1592
1593 #[test]
1594 fn with_pm_is_exactly_set_pm_in_builder_form() {
1595 for target in [false, true] {
1596 let built = TimePicker::create(3, 33).with_pm(target);
1597 let mut set = TimePicker::create(3, 33);
1598 set.set_pm(target);
1599 assert_eq!(built, set, "with_pm({target}) diverged from set_pm");
1600 }
1601 }
1602
1603 #[test]
1604 fn pm_in_24h_mode_is_inert_for_the_canonical_reading() {
1605 let am = TimePicker::create(15, 0);
1608 let pm = TimePicker::create(15, 0).with_pm(true);
1609 assert_eq!(
1610 am.state.inner.canonical_hour(),
1611 pm.state.inner.canonical_hour(),
1612 "the PM flag changed a 24-hour reading",
1613 );
1614 assert_eq!(am.state.inner.hour, pm.state.inner.hour);
1615 }
1616
1617 #[test]
1622 fn set_on_change_stores_the_function_pointer_and_the_payload_verbatim() {
1623 let mut p = TimePicker::create(1, 1);
1624 p.set_on_change(
1625 RefAny::new(0xDEAD_BEEF_u32),
1626 change_do_nothing as TimePickerOnChangeCallbackType,
1627 );
1628
1629 let c = p
1630 .state
1631 .on_change
1632 .as_ref()
1633 .expect("set_on_change did not store anything");
1634 assert_eq!(
1635 c.callback.cb as *const () as usize,
1636 change_do_nothing as *const () as usize,
1637 "the stored function pointer is not the one that was handed in",
1638 );
1639 assert!(
1640 matches!(c.callback.ctx, OptionRefAny::None),
1641 "a native Rust callback must not carry an FFI context",
1642 );
1643 let mut payload = c.refany.clone();
1644 assert_eq!(
1645 *payload.downcast_ref::<u32>().expect("the payload changed type"),
1646 0xDEAD_BEEF,
1647 );
1648 }
1649
1650 #[test]
1651 fn set_on_change_replaces_rather_than_accumulates() {
1652 let mut p = TimePicker::create(1, 1);
1653 p.set_on_change(RefAny::new(1u8), change_do_nothing as TimePickerOnChangeCallbackType);
1654 p.set_on_change(RefAny::new(2u8), change_refresh_all as TimePickerOnChangeCallbackType);
1655
1656 let c = p.state.on_change.as_ref().expect("the callback vanished");
1657 assert_eq!(
1658 c.callback.cb as *const () as usize,
1659 change_refresh_all as *const () as usize,
1660 "the second set_on_change did not win",
1661 );
1662 let mut payload = c.refany.clone();
1663 assert_eq!(*payload.downcast_ref::<u8>().expect("wrong payload type"), 2);
1664 }
1665
1666 #[test]
1667 fn set_on_change_does_not_disturb_the_time_or_the_container_style() {
1668 let before = TimePicker::create(23, 59);
1669 let mut after = TimePicker::create(23, 59);
1670 after.set_on_change(RefAny::new(0u8), change_do_nothing as TimePickerOnChangeCallbackType);
1671
1672 assert_eq!(after.state.inner, before.state.inner, "installing a callback moved the time");
1673 assert_eq!(
1674 properties(&after.container_style),
1675 properties(&before.container_style),
1676 "installing a callback restyled the container",
1677 );
1678 }
1679
1680 #[test]
1681 fn with_on_change_is_exactly_set_on_change_in_builder_form() {
1682 let built = TimePicker::create(5, 9)
1683 .with_on_change(RefAny::new(7u32), change_do_nothing as TimePickerOnChangeCallbackType);
1684 let mut set = TimePicker::create(5, 9);
1685 set.set_on_change(RefAny::new(7u32), change_do_nothing as TimePickerOnChangeCallbackType);
1686
1687 assert_eq!(built.state.inner, set.state.inner);
1688 let a = built.state.on_change.as_ref().expect("builder dropped the callback");
1689 let b = set.state.on_change.as_ref().expect("setter dropped the callback");
1690 assert_eq!(a.callback.cb as *const () as usize, b.callback.cb as *const () as usize);
1691
1692 let (mut pa, mut pb) = (a.refany.clone(), b.refany.clone());
1693 assert_eq!(
1694 *pa.downcast_ref::<u32>().expect("builder payload changed type"),
1695 *pb.downcast_ref::<u32>().expect("setter payload changed type"),
1696 );
1697 }
1698
1699 #[test]
1700 fn with_on_change_accepts_a_generic_callback_without_mangling_the_pointer() {
1701 let generic = Callback {
1706 cb: generic_shaped,
1707 ctx: OptionRefAny::None,
1708 };
1709 let expected = generic_shaped as *const () as usize;
1710
1711 let p = TimePicker::create(1, 1).with_on_change(RefAny::new(0u8), generic);
1712 let c = p.state.on_change.as_ref().expect("the generic callback was dropped");
1713 assert_eq!(
1714 c.callback.cb as *const () as usize,
1715 expected,
1716 "the Callback -> TimePickerOnChangeCallback transmute mangled the pointer",
1717 );
1718 }
1719
1720 #[test]
1721 fn installing_a_callback_survives_every_other_builder_step() {
1722 let p = TimePicker::create(9, 30)
1725 .with_on_change(RefAny::new(1u8), change_do_nothing as TimePickerOnChangeCallbackType)
1726 .with_24h(false)
1727 .with_pm(true);
1728 assert!(p.state.on_change.as_ref().is_some(), "a later builder dropped the callback");
1729 assert_eq!(p.state.inner.hour, 9);
1730 assert!(p.state.inner.is_pm);
1731 assert!(!p.state.inner.is_24h);
1732 }
1733
1734 #[test]
1739 fn swap_with_default_hands_out_the_original_and_leaves_a_default_behind() {
1740 let mut p = TimePicker::create(21, 45).with_24h(false).with_pm(true);
1741 let inner_before = p.state.inner;
1742 let taken = p.swap_with_default();
1743
1744 assert_eq!(taken.state.inner, inner_before, "the original state was not handed out");
1745 assert_eq!(p, TimePicker::default(), "the picker left behind is not a default one");
1746 }
1747
1748 #[test]
1749 fn swap_with_default_carries_the_callback_out_with_the_original() {
1750 let mut p = TimePicker::create(6, 6)
1753 .with_on_change(RefAny::new(3u8), change_do_nothing as TimePickerOnChangeCallbackType);
1754 let taken = p.swap_with_default();
1755
1756 assert!(
1757 taken.state.on_change.as_ref().is_some(),
1758 "the callback did not leave with the original",
1759 );
1760 assert!(
1761 p.state.on_change.as_ref().is_none(),
1762 "the callback stayed behind on the default",
1763 );
1764 }
1765
1766 #[test]
1767 fn swapping_a_default_repeatedly_is_idempotent() {
1768 let mut p = TimePicker::default();
1769 let first = p.swap_with_default();
1770 let second = p.swap_with_default();
1771 assert_eq!(first, TimePicker::default());
1772 assert_eq!(second, TimePicker::default());
1773 assert_eq!(p, TimePicker::default());
1774 }
1775
1776 #[test]
1777 fn swap_with_default_survives_a_hand_written_out_of_range_state() {
1778 let mut p = TimePicker::create(0, 0);
1779 p.state.inner.hour = u32::MAX;
1780 p.state.inner.minute = u32::MAX;
1781 let taken = p.swap_with_default();
1782
1783 assert_eq!(taken.state.inner.hour, u32::MAX, "swap normalised what it took out");
1784 assert_eq!(p.state.inner, TimePickerState::default(), "the replacement is not a default");
1785 }
1786
1787 #[test]
1792 fn dom_renders_three_columns_in_24h_mode_and_four_in_12h() {
1793 let h24 = TimePicker::create(9, 5).dom();
1794 assert!(matches!(h24.root.get_node_type(), NodeType::Div));
1795 assert_eq!(classes(&h24), vec![CLASS_CONTAINER.to_string()]);
1796 assert_eq!(h24.children.as_ref().len(), 3, "24-hour mode rendered an AM/PM toggle");
1797
1798 let h12 = TimePicker::create(9, 5).with_24h(false).dom();
1799 assert_eq!(h12.children.as_ref().len(), 4, "12-hour mode did not render an AM/PM toggle");
1800 assert_eq!(classes(&h12.children.as_ref()[3]), vec![CLASS_AMPM.to_string()]);
1801 }
1802
1803 #[test]
1804 fn dom_columns_carry_the_documented_classes_and_glyphs() {
1805 let dom = TimePicker::create(9, 5).dom();
1806 let (hour, sep, minute) = columns(&dom);
1807
1808 assert_eq!(classes(sep), vec![CLASS_SEPARATOR.to_string()]);
1809 assert_eq!(text_of(sep).as_deref(), Some(":"), "the separator is not a colon");
1810
1811 for (which, col) in [("hour", hour), ("minute", minute)] {
1812 assert_eq!(classes(col), vec![CLASS_SPINNER.to_string()], "{which}: wrong class");
1813 let cells = col.children.as_ref();
1814 assert_eq!(cells.len(), 3, "{which}: a spinner is up-arrow / value / down-arrow");
1815 assert_eq!(classes(&cells[0]), vec![CLASS_ARROW.to_string()]);
1816 assert_eq!(classes(&cells[1]), vec![CLASS_DISPLAY.to_string()]);
1817 assert_eq!(classes(&cells[2]), vec![CLASS_ARROW.to_string()]);
1818 assert_eq!(text_of(&cells[0]).as_deref(), Some(UP_GLYPH), "{which}: up arrow");
1819 assert_eq!(text_of(&cells[2]).as_deref(), Some(DOWN_GLYPH), "{which}: down arrow");
1820 }
1821 }
1822
1823 #[test]
1824 fn the_class_and_glyph_constants_are_the_ones_the_widget_declares() {
1825 let names = |c: &[IdOrClass]| -> Vec<String> {
1828 c.iter()
1829 .filter_map(|c| match c {
1830 IdOrClass::Class(s) => Some(s.as_str().to_string()),
1831 IdOrClass::Id(_) => None,
1832 })
1833 .collect()
1834 };
1835 assert_eq!(names(TIME_PICKER_CLASS), vec![CLASS_CONTAINER.to_string()]);
1836 assert_eq!(names(SPINNER_CLASS), vec![CLASS_SPINNER.to_string()]);
1837 assert_eq!(names(DISPLAY_CLASS), vec![CLASS_DISPLAY.to_string()]);
1838 assert_eq!(names(ARROW_CLASS), vec![CLASS_ARROW.to_string()]);
1839 assert_eq!(names(SEPARATOR_CLASS), vec![CLASS_SEPARATOR.to_string()]);
1840 assert_eq!(names(AMPM_CLASS), vec![CLASS_AMPM.to_string()]);
1841 assert_eq!(UP_ARROW.as_str(), UP_GLYPH);
1842 assert_eq!(DOWN_ARROW.as_str(), DOWN_GLYPH);
1843 assert_eq!(SEPARATOR_TEXT.as_str(), ":");
1844 }
1845
1846 #[test]
1847 fn dom_zero_pads_the_minute_but_not_the_hour() {
1848 assert_eq!(
1853 displayed(&TimePicker::create(9, 5).dom()),
1854 ("9".to_string(), "05".to_string()),
1855 );
1856 assert_eq!(
1857 displayed(&TimePicker::create(0, 0).dom()),
1858 ("0".to_string(), "00".to_string()),
1859 );
1860 assert_eq!(
1861 displayed(&TimePicker::create(23, 59).dom()),
1862 ("23".to_string(), "59".to_string()),
1863 );
1864 }
1865
1866 #[test]
1867 fn dom_text_round_trips_the_whole_state_for_every_hour_and_minute() {
1868 for hour in 0..24u32 {
1872 for minute in [0u32, 1, 9, 10, 30, 58, 59] {
1873 let (h, m) = displayed(&TimePicker::create(hour, minute).dom());
1874 assert_eq!(
1875 h.parse::<u32>().expect("the hour display is not a number"),
1876 hour,
1877 "the hour display drifted at {hour}:{minute}",
1878 );
1879 assert_eq!(
1880 m.parse::<u32>().expect("the minute display is not a number"),
1881 minute,
1882 "the minute display drifted at {hour}:{minute}",
1883 );
1884 assert_eq!(m.chars().count(), 2, "the minute is not zero-padded at {hour}:{minute}");
1885 }
1886 }
1887 }
1888
1889 #[test]
1890 fn dom_renders_a_hand_written_out_of_range_state_without_panicking() {
1891 let mut p = TimePicker::create(0, 0);
1894 p.state.inner.hour = u32::MAX;
1895 p.state.inner.minute = u32::MAX;
1896 let (h, m) = displayed(&p.dom());
1897 assert_eq!(h, u32::MAX.to_string(), "the hour display truncated a huge value");
1898 assert_eq!(m, u32::MAX.to_string(), "the minute display truncated a huge value");
1899 }
1900
1901 #[test]
1902 fn dom_ampm_label_tracks_the_pm_flag_and_only_exists_in_12h_mode() {
1903 assert_eq!(
1904 ampm_label(&TimePicker::create(6, 0).with_24h(false).dom()).as_deref(),
1905 Some("AM"),
1906 );
1907 assert_eq!(
1908 ampm_label(&TimePicker::create(6, 0).with_24h(false).with_pm(true).dom()).as_deref(),
1909 Some("PM"),
1910 );
1911 assert_eq!(ampm_label(&TimePicker::create(6, 0).with_pm(true).dom()), None);
1913 }
1914
1915 #[test]
1916 fn dom_wires_each_of_the_five_handlers_exactly_once() {
1917 let styled = StyledDom::create_from_dom(TimePicker::create(8, 8).with_24h(false).dom());
1918 for (name, handler) in [
1919 ("on_hour_up", on_hour_up as usize),
1920 ("on_hour_down", on_hour_down as usize),
1921 ("on_minute_up", on_minute_up as usize),
1922 ("on_minute_down", on_minute_down as usize),
1923 ("on_ampm_toggle", on_ampm_toggle as usize),
1924 ] {
1925 let count = styled
1926 .node_data
1927 .as_ref()
1928 .iter()
1929 .flat_map(|nd| nd.callbacks.as_ref().iter())
1930 .filter(|cb| cb.callback.cb == handler)
1931 .count();
1932 assert_eq!(count, 1, "{name} is wired to {count} node(s), not exactly one");
1933 }
1934 }
1935
1936 #[test]
1937 fn dom_registers_every_handler_on_mouse_up_and_makes_the_cell_focusable() {
1938 let styled = StyledDom::create_from_dom(TimePicker::create(8, 8).with_24h(false).dom());
1939 let mut interactive = 0;
1940 for nd in styled.node_data.as_ref() {
1941 for cb in nd.callbacks.as_ref() {
1942 assert_eq!(
1943 cb.event,
1944 EventFilter::Hover(HoverEventFilter::MouseUp),
1945 "a time-picker cell fires on something other than mouse-up",
1946 );
1947 assert!(
1948 matches!(cb.callback.ctx, OptionRefAny::None),
1949 "a native handler carries an FFI context",
1950 );
1951 interactive += 1;
1952 }
1953 if !nd.callbacks.as_ref().is_empty() {
1954 assert_eq!(
1955 nd.flags.get_tab_index(),
1956 Some(TabIndex::Auto),
1957 "a clickable time-picker cell is not keyboard-focusable",
1958 );
1959 }
1960 }
1961 assert_eq!(interactive, 5, "12-hour mode must expose 4 arrows + 1 AM/PM toggle");
1962 }
1963
1964 #[test]
1965 fn dom_leaves_the_displays_and_the_separator_inert() {
1966 let styled = StyledDom::create_from_dom(TimePicker::create(8, 8).with_24h(false).dom());
1969 for idx in [
1970 N_CONTAINER,
1971 N_HOUR_SPINNER,
1972 N_HOUR_DISPLAY,
1973 N_SEPARATOR,
1974 N_MINUTE_SPINNER,
1975 N_MINUTE_DISPLAY,
1976 ] {
1977 assert!(
1978 styled.node_data.as_ref()[idx].callbacks.as_ref().is_empty(),
1979 "flattened node {idx} registered a click handler it should not have",
1980 );
1981 }
1982 }
1983
1984 #[test]
1985 fn dom_shares_one_state_refany_across_every_handler() {
1986 let styled = StyledDom::create_from_dom(TimePicker::create(5, 5).with_24h(false).dom());
1989 let payloads = state_payloads(&styled);
1990 assert_eq!(payloads.len(), 5, "not every handler carries the widget state");
1991
1992 {
1993 let mut first = payloads[0].clone();
1994 let mut w = first
1995 .downcast_mut::<TimePickerStateWrapper>()
1996 .expect("the state changed type");
1997 w.inner.minute = 42;
1998 }
1999 for (i, p) in payloads.iter().enumerate() {
2000 assert_eq!(
2001 read_state(p).minute,
2002 42,
2003 "handler payload #{i} is a private copy of the state",
2004 );
2005 }
2006 }
2007
2008 #[test]
2009 fn dom_keeps_its_cached_child_count_in_sync_with_the_tree() {
2010 for is_24h in [false, true] {
2013 for (h, m) in [(0u32, 0u32), (23, 59), (u32::MAX, u32::MAX)] {
2014 let dom = TimePicker::create(h, m).with_24h(is_24h).dom();
2015 let expected_nodes = if is_24h { 10 } else { 11 };
2016 assert_eq!(
2017 dom.estimated_total_children,
2018 descendants(&dom),
2019 "{h}:{m} 24h={is_24h}: the cached descendant count is wrong",
2020 );
2021 let styled = StyledDom::create_from_dom(dom);
2022 assert_eq!(
2023 styled.node_data.as_ref().len(),
2024 expected_nodes,
2025 "{h}:{m} 24h={is_24h}: the widget did not flatten to {expected_nodes} nodes",
2026 );
2027 }
2028 }
2029 }
2030
2031 #[test]
2032 fn flattened_layout_is_the_fixed_ten_or_eleven_nodes() {
2033 let styled = StyledDom::create_from_dom(TimePicker::create(7, 8).with_24h(false).dom());
2035 for (idx, class, text) in [
2036 (N_CONTAINER, CLASS_CONTAINER, None),
2037 (N_HOUR_SPINNER, CLASS_SPINNER, None),
2038 (N_HOUR_UP, CLASS_ARROW, Some(UP_GLYPH)),
2039 (N_HOUR_DISPLAY, CLASS_DISPLAY, Some("7")),
2040 (N_HOUR_DOWN, CLASS_ARROW, Some(DOWN_GLYPH)),
2041 (N_SEPARATOR, CLASS_SEPARATOR, Some(":")),
2042 (N_MINUTE_SPINNER, CLASS_SPINNER, None),
2043 (N_MINUTE_UP, CLASS_ARROW, Some(UP_GLYPH)),
2044 (N_MINUTE_DISPLAY, CLASS_DISPLAY, Some("08")),
2045 (N_MINUTE_DOWN, CLASS_ARROW, Some(DOWN_GLYPH)),
2046 (N_AMPM, CLASS_AMPM, Some("AM")),
2047 ] {
2048 assert_eq!(
2049 flat_classes(&styled, idx),
2050 vec![class.to_string()],
2051 "flattened node {idx} is not the {class} node",
2052 );
2053 assert_eq!(
2054 flat_text(&styled, idx).as_deref(),
2055 text,
2056 "flattened node {idx} renders the wrong text",
2057 );
2058 }
2059 assert_eq!(
2060 wired_to(&styled, on_hour_up as usize).0,
2061 node(N_HOUR_UP),
2062 "the hour up arrow is not where the indices say it is",
2063 );
2064 assert_eq!(wired_to(&styled, on_hour_down as usize).0, node(N_HOUR_DOWN));
2065 assert_eq!(wired_to(&styled, on_minute_up as usize).0, node(N_MINUTE_UP));
2066 assert_eq!(wired_to(&styled, on_minute_down as usize).0, node(N_MINUTE_DOWN));
2067 assert_eq!(wired_to(&styled, on_ampm_toggle as usize).0, node(N_AMPM));
2068 }
2069
2070 #[test]
2071 fn from_timepicker_for_dom_is_the_dom_method() {
2072 let via_from: Dom = TimePicker::create(4, 20).into();
2073 let via_dom = TimePicker::create(4, 20).dom();
2074 assert_eq!(classes(&via_from), classes(&via_dom));
2075 assert_eq!(displayed(&via_from), displayed(&via_dom));
2076 assert_eq!(via_from.children.as_ref().len(), via_dom.children.as_ref().len());
2077 assert_eq!(via_from.estimated_total_children, via_dom.estimated_total_children);
2078 }
2079
2080 #[test]
2085 fn build_spinner_stores_the_handler_pointers_verbatim_at_the_usize_extremes() {
2086 for (up, down) in [
2091 (0usize, 0usize),
2092 (0, usize::MAX),
2093 (usize::MAX, 0),
2094 (usize::MAX, usize::MAX),
2095 (1, 2),
2096 (usize::MAX / 2, usize::MAX - 1),
2097 ] {
2098 let dom = build_spinner(AzString::from_const_str("0"), RefAny::new(0u8), up, down);
2099 let cells = dom.children.as_ref();
2100 assert_eq!(cells.len(), 3);
2101 assert_eq!(
2102 cells[0].root.callbacks.as_ref()[0].callback.cb, up,
2103 "the up handler {up} was mangled",
2104 );
2105 assert_eq!(
2106 cells[2].root.callbacks.as_ref()[0].callback.cb, down,
2107 "the down handler {down} was mangled",
2108 );
2109 }
2110 }
2111
2112 #[test]
2113 fn build_spinner_puts_the_value_between_the_two_arrows() {
2114 let dom = build_spinner(AzString::from_const_str("42"), RefAny::new(0u8), 1, 2);
2115 assert_eq!(classes(&dom), vec![CLASS_SPINNER.to_string()]);
2116 let cells = dom.children.as_ref();
2117 assert_eq!(text_of(&cells[0]).as_deref(), Some(UP_GLYPH));
2118 assert_eq!(text_of(&cells[1]).as_deref(), Some("42"));
2119 assert_eq!(text_of(&cells[2]).as_deref(), Some(DOWN_GLYPH));
2120 assert!(
2121 cells[1].root.callbacks.as_ref().is_empty(),
2122 "the value display must not be clickable",
2123 );
2124 }
2125
2126 #[test]
2127 fn build_spinner_preserves_every_kind_of_value_string_byte_for_byte() {
2128 let long = "9".repeat(4096);
2132 let values: Vec<String> = vec![
2133 String::new(),
2134 " ".to_string(),
2135 "\0".to_string(),
2136 "\n\t".to_string(),
2137 "٣٠".to_string(), "\u{1F55B}".to_string(), "e\u{0301}".to_string(), "\u{202E}12".to_string(), "-1".to_string(),
2142 "٩٩:٩٩".to_string(),
2143 long,
2144 ];
2145 for v in values {
2146 let dom = build_spinner(AzString::from(v.clone()), RefAny::new(0u8), 1, 2);
2147 let shown = text_of(&dom.children.as_ref()[1]);
2148 assert_eq!(
2149 shown.as_deref(),
2150 Some(v.as_str()),
2151 "the spinner value was not preserved verbatim ({} bytes)",
2152 v.len(),
2153 );
2154 }
2155 }
2156
2157 #[test]
2158 fn build_spinner_shares_the_state_between_both_arrows() {
2159 let state = RefAny::new(TimePickerStateWrapper::default());
2160 let dom = build_spinner(AzString::from_const_str("0"), state.clone(), 1, 2);
2161 let cells = dom.children.as_ref();
2162
2163 {
2164 let mut up_payload = cells[0].root.callbacks.as_ref()[0].refany.clone();
2165 let mut w = up_payload
2166 .downcast_mut::<TimePickerStateWrapper>()
2167 .expect("the up arrow does not carry the state");
2168 w.inner.hour = 17;
2169 }
2170 let mut down_payload = cells[2].root.callbacks.as_ref()[0].refany.clone();
2171 let seen = down_payload
2172 .downcast_ref::<TimePickerStateWrapper>()
2173 .expect("the down arrow does not carry the state")
2174 .inner
2175 .hour;
2176 assert_eq!(seen, 17, "the two arrows of one spinner hold separate states");
2177 assert_eq!(read_state(&state).hour, 17, "the caller's own handle was not shared");
2178 }
2179
2180 #[test]
2181 fn build_spinner_makes_both_arrows_focusable_click_targets() {
2182 let dom = build_spinner(AzString::from_const_str("0"), RefAny::new(0u8), 1, 2);
2183 for (which, cell) in [("up", 0usize), ("down", 2usize)] {
2184 let cell = &dom.children.as_ref()[cell];
2185 let cbs = cell.root.callbacks.as_ref();
2186 assert_eq!(cbs.len(), 1, "{which}: an arrow registers exactly one handler");
2187 assert_eq!(cbs[0].event, EventFilter::Hover(HoverEventFilter::MouseUp));
2188 assert_eq!(
2189 cell.root.flags.get_tab_index(),
2190 Some(TabIndex::Auto),
2191 "{which}: the arrow is not keyboard-focusable",
2192 );
2193 assert_eq!(classes(cell), vec![CLASS_ARROW.to_string()]);
2194 }
2195 }
2196
2197 #[test]
2198 fn build_spinner_reports_its_three_children() {
2199 for value in ["", "0", "999999"] {
2200 let dom = build_spinner(AzString::from(value.to_string()), RefAny::new(0u8), 1, 2);
2201 assert_eq!(dom.estimated_total_children, descendants(&dom));
2202 assert_eq!(dom.estimated_total_children, 3);
2203 }
2204 }
2205
2206 #[test]
2211 fn hour_arrows_move_the_hour_by_one_and_retext_the_hour_display() {
2212 let (styled, shared) = laid_out(TimePicker::create(9, 30));
2213 let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2214
2215 let (update, changes) = press(styled, &payload, hit, on_hour_up);
2216
2217 assert_eq!(read_state(&shared).hour, 10, "the up arrow did not increment the hour");
2218 assert_eq!(read_state(&shared).minute, 30, "the hour arrow moved the minute");
2219 assert_eq!(update, Update::DoNothing, "no callback is installed, so nothing to report");
2220 assert_eq!(
2221 only_retext(&changes),
2222 (node(N_HOUR_DISPLAY), "10".to_string()),
2223 "the up arrow retexted the wrong node (or with the wrong text)",
2224 );
2225 }
2226
2227 #[test]
2228 fn minute_arrows_move_the_minute_and_retext_it_zero_padded() {
2229 let (styled, shared) = laid_out(TimePicker::create(9, 30));
2230 let (hit, payload) = wired_to(&styled, on_minute_down as usize);
2231
2232 let (_, changes) = press(styled, &payload, hit, on_minute_down);
2233
2234 assert_eq!(read_state(&shared).minute, 29, "the down arrow did not decrement the minute");
2235 assert_eq!(read_state(&shared).hour, 9, "the minute arrow moved the hour");
2236 assert_eq!(only_retext(&changes), (node(N_MINUTE_DISPLAY), "29".to_string()));
2237
2238 let (styled, _) = laid_out(TimePicker::create(9, 10));
2240 let (hit, payload) = wired_to(&styled, on_minute_down as usize);
2241 let (_, changes) = press(styled, &payload, hit, on_minute_down);
2242 assert_eq!(
2243 only_retext(&changes).1,
2244 "09",
2245 "the retext dropped the zero padding the first render used",
2246 );
2247 }
2248
2249 #[test]
2250 fn the_hour_clamps_at_both_ends_of_the_24_hour_band() {
2251 for (start, handler, want) in [
2252 (23u32, on_hour_up as extern "C" fn(RefAny, CallbackInfo) -> Update, 23u32),
2253 (0, on_hour_down as extern "C" fn(RefAny, CallbackInfo) -> Update, 0),
2254 ] {
2255 let (styled, shared) = laid_out(TimePicker::create(start, 0));
2256 let handler_addr = handler as usize;
2257 let (hit, payload) = wired_to(&styled, handler_addr);
2258
2259 let (_, changes) = press(styled, &payload, hit, handler);
2260
2261 assert_eq!(read_state(&shared).hour, want, "the hour escaped the band from {start}");
2262 assert_eq!(
2263 only_retext(&changes).1,
2264 want.to_string(),
2265 "the clamped hour was retexted with something else",
2266 );
2267 }
2268 }
2269
2270 #[test]
2271 fn the_hour_clamps_to_the_narrow_band_in_12_hour_mode() {
2272 for (start, handler, want) in [
2273 (12u32, on_hour_up as extern "C" fn(RefAny, CallbackInfo) -> Update, 12u32),
2274 (1, on_hour_down as extern "C" fn(RefAny, CallbackInfo) -> Update, 1),
2275 (11, on_hour_up as extern "C" fn(RefAny, CallbackInfo) -> Update, 12),
2276 (2, on_hour_down as extern "C" fn(RefAny, CallbackInfo) -> Update, 1),
2277 ] {
2278 let (styled, shared) = laid_out(TimePicker::create(start, 0).with_24h(false));
2279 let (hit, payload) = wired_to(&styled, handler as usize);
2280
2281 let (_, _) = press(styled, &payload, hit, handler);
2282
2283 assert_eq!(
2284 read_state(&shared).hour,
2285 want,
2286 "12-hour mode: pressing from {start} did not land on {want}",
2287 );
2288 }
2289 }
2290
2291 #[test]
2292 fn the_minute_clamps_and_never_carries_into_the_hour() {
2293 for (start, handler, want) in [
2296 (59u32, on_minute_up as extern "C" fn(RefAny, CallbackInfo) -> Update, 59u32),
2297 (0, on_minute_down as extern "C" fn(RefAny, CallbackInfo) -> Update, 0),
2298 ] {
2299 let (styled, shared) = laid_out(TimePicker::create(12, start));
2300 let (hit, payload) = wired_to(&styled, handler as usize);
2301
2302 let (_, changes) = press(styled, &payload, hit, handler);
2303
2304 assert_eq!(read_state(&shared).minute, want, "the minute escaped 0..=59 from {start}");
2305 assert_eq!(read_state(&shared).hour, 12, "the minute carried into the hour");
2306 assert_eq!(
2307 only_retext(&changes),
2308 (node(N_MINUTE_DISPLAY), format!("{want:02}")),
2309 "the clamped minute was retexted wrongly",
2310 );
2311 }
2312 }
2313
2314 #[test]
2315 fn holding_an_arrow_walks_the_whole_band_and_then_stops() {
2316 let (styled, shared) = laid_out(TimePicker::create(0, 0));
2319 let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2320 press_n(styled, &payload, hit, on_hour_up, 200);
2321 assert_eq!(read_state(&shared).hour, 23, "200 up-presses did not saturate at 23");
2322
2323 let (styled, shared) = laid_out(TimePicker::create(23, 59));
2324 let (hit, payload) = wired_to(&styled, on_minute_down as usize);
2325 press_n(styled, &payload, hit, on_minute_down, 200);
2326 assert_eq!(read_state(&shared).minute, 0, "200 down-presses did not saturate at 0");
2327 assert_eq!(read_state(&shared).hour, 23, "the saturating minute borrowed from the hour");
2328 }
2329
2330 #[test]
2331 fn every_step_of_a_full_sweep_is_reported_exactly_once() {
2332 let (styled, shared) = laid_out(TimePicker::create(0, 0));
2336 let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2337 let (_, changes) = press_n(styled, &payload, hit, on_hour_up, 23);
2338
2339 let texts: Vec<String> = pushed_texts(&changes).into_iter().map(|(_, t)| t).collect();
2340 assert_eq!(
2341 texts,
2342 (1..=23u32).map(|h| h.to_string()).collect::<Vec<_>>(),
2343 "the sweep did not report every hour exactly once, in order",
2344 );
2345 assert_eq!(read_state(&shared).hour, 23);
2346 }
2347
2348 #[test]
2349 fn a_press_on_a_node_without_a_parent_changes_nothing_at_all() {
2350 let (styled, shared) = laid_out(TimePicker::create(9, 30));
2353 let (_, payload) = wired_to(&styled, on_hour_up as usize);
2354
2355 let (update, changes) = press(styled, &payload, node(N_CONTAINER), on_hour_up);
2356
2357 assert_eq!(update, Update::DoNothing);
2358 assert!(changes.is_empty(), "a parentless hit still retexted something");
2359 assert_eq!(
2360 read_state(&shared),
2361 TimePicker::create(9, 30).state.inner,
2362 "a hit that could not be resolved mutated the state anyway",
2363 );
2364 }
2365
2366 #[test]
2367 fn a_press_on_a_detached_or_out_of_range_node_changes_nothing_at_all() {
2368 for (name, hit) in [
2369 ("no node at all", node_none()),
2370 ("one past the end", node(11)),
2371 ("far out of range", node(usize::MAX / 2)),
2372 ] {
2373 let (styled, shared) = laid_out(TimePicker::create(9, 30));
2374 let (_, payload) = wired_to(&styled, on_minute_up as usize);
2375
2376 let (update, changes) = press(styled, &payload, hit, on_minute_up);
2377
2378 assert_eq!(update, Update::DoNothing, "{name}: unexpected verdict");
2379 assert!(changes.is_empty(), "{name}: a bad hit still retexted something");
2380 assert_eq!(
2381 read_state(&shared).minute,
2382 30,
2383 "{name}: a bad hit mutated the state",
2384 );
2385 }
2386 }
2387
2388 #[test]
2389 fn the_display_lookup_is_positional_and_trusts_whatever_node_it_lands_on() {
2390 let (styled, shared) = laid_out(TimePicker::create(9, 30));
2397 let (_, payload) = wired_to(&styled, on_hour_up as usize);
2398
2399 let (update, changes) = press(styled, &payload, node(N_SEPARATOR), on_hour_up);
2400
2401 assert_eq!(update, Update::DoNothing);
2402 assert_eq!(read_state(&shared).hour, 10, "the edit itself did not happen");
2403 assert_eq!(read_state(&shared).minute, 30, "an hour press moved the minute");
2404 assert_eq!(
2405 only_retext(&changes),
2406 (node(N_SEPARATOR), "10".to_string()),
2407 "the positional lookup landed somewhere other than the separator",
2408 );
2409 }
2410
2411 #[test]
2412 fn a_press_with_a_foreign_payload_is_declined_without_touching_the_dom() {
2413 for payload in [
2416 RefAny::new(0u8),
2417 RefAny::new(TimePickerState::default()),
2418 RefAny::new(String::from("not a time picker")),
2419 ] {
2420 let (styled, shared) = laid_out(TimePicker::create(9, 30));
2421 let (hit, _) = wired_to(&styled, on_hour_up as usize);
2422
2423 let (update, changes) = press(styled, &payload, hit, on_hour_up);
2424
2425 assert_eq!(update, Update::DoNothing, "a foreign payload was accepted");
2426 assert!(changes.is_empty(), "a foreign payload still retexted the display");
2427 assert_eq!(read_state(&shared).hour, 9, "a foreign payload moved the real state");
2428 }
2429 }
2430
2431 #[test]
2432 fn adjust_spinner_saturates_at_the_extreme_deltas_it_can_be_handed() {
2433 for (delta, want_hour, want_minute) in [
2441 (i64::MAX, 23u32, 59u32),
2442 (i64::MIN, 0, 0),
2443 (i64::MAX / 2, 23, 59),
2444 (i64::MIN / 2, 0, 0),
2445 (1_000_000_000, 23, 59),
2446 (-1_000_000_000, 0, 0),
2447 ] {
2448 let (styled, _) = laid_out(TimePicker::create(0, 0));
2449 let (hit, _) = wired_to(&styled, on_hour_up as usize);
2450 let state = wrapper(TimePickerState::default());
2451
2452 let (_, _) = with_info(styled, hit, |info| {
2453 let _ = adjust_spinner(state.clone(), *info, true, delta);
2454 let _ = adjust_spinner(state.clone(), *info, false, delta);
2455 });
2456
2457 assert_eq!(
2458 (read_state(&state).hour, read_state(&state).minute),
2459 (want_hour, want_minute),
2460 "delta {delta} did not saturate",
2461 );
2462 }
2463 }
2464
2465 #[test]
2466 fn adjust_spinner_saturates_a_hand_written_extreme_hour_back_into_the_band() {
2467 for (start_hour, delta, want) in [
2470 (u32::MAX, i64::MIN, 0u32),
2471 (u32::MAX, -1, 23),
2472 (u32::MAX, 0, 23),
2473 (u32::MAX, 1, 23),
2474 (0, -1, 0),
2475 ] {
2476 let (styled, _) = laid_out(TimePicker::create(0, 0));
2477 let (hit, _) = wired_to(&styled, on_hour_up as usize);
2478 let state = wrapper(TimePickerState {
2479 hour: start_hour,
2480 minute: 0,
2481 is_pm: false,
2482 is_24h: true,
2483 });
2484
2485 let (_, changes) = with_info(styled, hit, |info| {
2486 adjust_spinner(state.clone(), *info, true, delta)
2487 });
2488
2489 assert_eq!(
2490 read_state(&state).hour,
2491 want,
2492 "hour {start_hour} with delta {delta} did not clamp to {want}",
2493 );
2494 assert_eq!(
2495 only_retext(&changes).1,
2496 want.to_string(),
2497 "the clamped value and the retext disagree",
2498 );
2499 }
2500 }
2501
2502 #[test]
2503 fn adjust_spinner_with_a_zero_delta_still_retexts_and_notifies() {
2504 let probe = log_refany();
2507 let (styled, shared) = laid_out(
2508 TimePicker::create(6, 7)
2509 .with_on_change(probe.clone(), record_change as TimePickerOnChangeCallbackType),
2510 );
2511 let (hit, _) = wired_to(&styled, on_hour_up as usize);
2512
2513 let (update, changes) = with_info(styled, hit, |info| {
2514 adjust_spinner(shared.clone(), *info, false, 0)
2515 });
2516
2517 assert_eq!(read_state(&shared).minute, 7, "a zero delta moved the value");
2518 assert_eq!(only_retext(&changes).1, "07", "a zero delta skipped the re-sync");
2519 assert_eq!(update, Update::RefreshDom, "the host's verdict was swallowed");
2520 assert_eq!(read_log(&probe).seen.len(), 1, "a zero delta did not notify the host");
2521 }
2522
2523 #[test]
2524 fn adjust_spinner_retexts_the_display_of_the_spinner_that_was_clicked() {
2525 for (handler, want_node) in [
2528 (on_hour_up as usize, N_HOUR_DISPLAY),
2529 (on_hour_down as usize, N_HOUR_DISPLAY),
2530 (on_minute_up as usize, N_MINUTE_DISPLAY),
2531 (on_minute_down as usize, N_MINUTE_DISPLAY),
2532 ] {
2533 let (styled, _) = laid_out(TimePicker::create(6, 30));
2534 let (hit, payload) = wired_to(&styled, handler);
2535 let is_hour = want_node == N_HOUR_DISPLAY;
2536
2537 let (_, changes) = with_info(styled, hit, |info| {
2538 adjust_spinner(payload.clone(), *info, is_hour, 1)
2539 });
2540
2541 assert_eq!(
2542 only_retext(&changes).0,
2543 node(want_node),
2544 "a press on node {hit:?} retexted the wrong display",
2545 );
2546 }
2547 }
2548
2549 #[test]
2554 fn the_change_callback_sees_the_state_after_the_edit_and_its_verdict_is_forwarded() {
2555 let probe = log_refany();
2559 let (styled, shared) = laid_out(
2560 TimePicker::create(9, 30)
2561 .with_on_change(probe.clone(), record_change as TimePickerOnChangeCallbackType),
2562 );
2563 let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2564
2565 let (update, changes) = press(styled, &payload, hit, on_hour_up);
2566
2567 let log = read_log(&probe);
2568 assert_eq!(
2569 log.seen,
2570 vec![TimePickerState {
2571 hour: 10,
2572 minute: 30,
2573 is_pm: false,
2574 is_24h: true,
2575 }],
2576 "the change callback was not called exactly once with the NEW state",
2577 );
2578 assert_eq!(
2579 log.payload, 0xDEAD_BEEF,
2580 "the callback was handed something other than the user's own RefAny",
2581 );
2582 assert_eq!(update, Update::RefreshDom, "the user callback's Update was swallowed");
2583 assert_eq!(read_state(&shared).hour, 10);
2584 assert_eq!(changes.len(), 1, "the retext was skipped because a callback ran");
2585 }
2586
2587 #[test]
2588 fn the_change_callback_still_fires_when_the_press_was_clamped_away() {
2589 let probe = log_refany();
2593 let (styled, _) = laid_out(
2594 TimePicker::create(23, 0)
2595 .with_on_change(probe.clone(), record_change as TimePickerOnChangeCallbackType),
2596 );
2597 let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2598
2599 press_n(styled, &payload, hit, on_hour_up, 3);
2600
2601 let log = read_log(&probe);
2602 assert_eq!(log.seen.len(), 3, "a clamped press stopped notifying the host");
2603 assert!(
2604 log.seen.iter().all(|s| s.hour == 23),
2605 "a clamped press reported a value the widget does not hold",
2606 );
2607 }
2608
2609 #[test]
2610 fn a_declining_change_callback_does_not_suppress_the_retext() {
2611 let (styled, shared) = laid_out(
2612 TimePicker::create(9, 30)
2613 .with_on_change(RefAny::new(0u8), change_do_nothing as TimePickerOnChangeCallbackType),
2614 );
2615 let (hit, payload) = wired_to(&styled, on_minute_up as usize);
2616
2617 let (update, changes) = press(styled, &payload, hit, on_minute_up);
2618
2619 assert_eq!(update, Update::DoNothing);
2620 assert_eq!(read_state(&shared).minute, 31);
2621 assert_eq!(
2622 only_retext(&changes),
2623 (node(N_MINUTE_DISPLAY), "31".to_string()),
2624 "a DoNothing user callback suppressed the widget's own repaint",
2625 );
2626 }
2627
2628 #[test]
2629 fn every_verdict_the_change_callback_returns_is_forwarded_unchanged() {
2630 for (cb, want) in [
2631 (change_do_nothing as TimePickerOnChangeCallbackType, Update::DoNothing),
2632 (change_refresh_all as TimePickerOnChangeCallbackType, Update::RefreshDomAllWindows),
2633 (record_change as TimePickerOnChangeCallbackType, Update::RefreshDom),
2634 ] {
2635 let (styled, _) = laid_out(
2636 TimePicker::create(9, 30).with_on_change(log_refany(), cb),
2637 );
2638 let (hit, payload) = wired_to(&styled, on_hour_down as usize);
2639 let (update, _) = press(styled, &payload, hit, on_hour_down);
2640 assert_eq!(update, want, "a user verdict was rewritten on its way out");
2641 }
2642 }
2643
2644 #[derive(Debug)]
2647 struct ReentrantProbe {
2648 state: RefAny,
2649 got_mut: Option<bool>,
2650 got_ref: Option<bool>,
2651 }
2652
2653 extern "C" fn probe_reentrancy(
2654 mut data: RefAny,
2655 _info: CallbackInfo,
2656 _state: TimePickerState,
2657 ) -> Update {
2658 if let Some(mut p) = data.downcast_mut::<ReentrantProbe>() {
2659 let mut s = p.state.clone();
2660 let got_mut = s.downcast_mut::<TimePickerStateWrapper>().is_some();
2661 let got_ref = s.downcast_ref::<TimePickerStateWrapper>().is_some();
2662 p.got_mut = Some(got_mut);
2663 p.got_ref = Some(got_ref);
2664 }
2665 Update::DoNothing
2666 }
2667
2668 #[test]
2669 fn a_reentrant_callback_is_refused_the_state_instead_of_aliasing_it() {
2670 let (styled, shared) = laid_out(TimePicker::create(9, 30));
2675 let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2676
2677 let probe = RefAny::new(ReentrantProbe {
2678 state: shared.clone(),
2679 got_mut: None,
2680 got_ref: None,
2681 });
2682 {
2683 let mut s = shared.clone();
2684 let mut w = s
2685 .downcast_mut::<TimePickerStateWrapper>()
2686 .expect("the state changed type");
2687 w.on_change = Some(TimePickerOnChange {
2688 callback: TimePickerOnChangeCallback::from(
2689 probe_reentrancy as TimePickerOnChangeCallbackType,
2690 ),
2691 refany: probe.clone(),
2692 })
2693 .into();
2694 }
2695
2696 let (update, _) = press(styled, &payload, hit, on_hour_up);
2697 assert_eq!(update, Update::DoNothing);
2698
2699 {
2700 let mut p = probe.clone();
2701 let seen = p
2702 .downcast_ref::<ReentrantProbe>()
2703 .expect("the probe changed type");
2704 assert_eq!(
2705 seen.got_mut,
2706 Some(false),
2707 "a re-entrant downcast_mut handed out a second &mut to the live state",
2708 );
2709 assert_eq!(
2710 seen.got_ref,
2711 Some(false),
2712 "a re-entrant downcast_ref aliased a live &mut borrow",
2713 );
2714 }
2715 assert_eq!(read_state(&shared).hour, 10, "the edit itself was lost");
2716
2717 {
2719 let mut s = shared.clone();
2720 if let Some(mut w) = s.downcast_mut::<TimePickerStateWrapper>() {
2721 w.on_change = None.into();
2722 };
2723 }
2724 }
2725
2726 #[test]
2731 fn the_ampm_toggle_flips_the_flag_and_retexts_the_node_that_was_hit() {
2732 let (styled, shared) = laid_out(TimePicker::create(9, 30).with_24h(false));
2733 let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
2734 assert_eq!(hit, node(N_AMPM));
2735
2736 let (update, changes) = press(styled, &payload, hit, on_ampm_toggle);
2737
2738 assert!(read_state(&shared).is_pm, "the toggle did not flip AM -> PM");
2739 assert_eq!(update, Update::DoNothing);
2740 assert_eq!(
2741 only_retext(&changes),
2742 (node(N_AMPM), "PM".to_string()),
2743 "the toggle did not relabel itself",
2744 );
2745 }
2746
2747 #[test]
2748 fn the_ampm_toggle_is_an_involution() {
2749 let (styled, shared) = laid_out(TimePicker::create(9, 30).with_24h(false).with_pm(true));
2750 let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
2751
2752 let (_, changes) = press_n(styled, &payload, hit, on_ampm_toggle, 4);
2753
2754 assert!(read_state(&shared).is_pm, "four flips did not return to PM");
2755 let labels: Vec<String> = pushed_texts(&changes).into_iter().map(|(_, t)| t).collect();
2756 assert_eq!(
2757 labels,
2758 vec!["AM", "PM", "AM", "PM"],
2759 "the labels did not alternate with the flag",
2760 );
2761 }
2762
2763 #[test]
2764 fn the_ampm_toggle_moves_the_canonical_hour_by_exactly_twelve() {
2765 for hour in 1..=12u32 {
2766 let (styled, shared) = laid_out(TimePicker::create(hour, 0).with_24h(false));
2767 let before = read_state(&shared).canonical_hour();
2768 let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
2769
2770 press(styled, &payload, hit, on_ampm_toggle);
2771
2772 let after = read_state(&shared).canonical_hour();
2773 assert_eq!(
2774 after,
2775 before + 12,
2776 "toggling {hour} AM did not move the canonical hour by twelve",
2777 );
2778 assert!(after < 24, "the toggle pushed the canonical hour out of the day");
2779 }
2780 }
2781
2782 #[test]
2783 fn the_ampm_toggle_leaves_the_hour_and_the_minute_alone() {
2784 let (styled, shared) = laid_out(TimePicker::create(11, 45).with_24h(false));
2785 let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
2786
2787 press(styled, &payload, hit, on_ampm_toggle);
2788
2789 let s = read_state(&shared);
2790 assert_eq!((s.hour, s.minute), (11, 45), "the toggle moved the time itself");
2791 assert!(!s.is_24h, "the toggle changed the display mode");
2792 }
2793
2794 #[test]
2795 fn the_ampm_toggle_notifies_the_host_with_the_new_flag() {
2796 let probe = log_refany();
2797 let (styled, _) = laid_out(
2798 TimePicker::create(3, 15)
2799 .with_24h(false)
2800 .with_on_change(probe.clone(), record_change as TimePickerOnChangeCallbackType),
2801 );
2802 let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
2803
2804 let (update, _) = press(styled, &payload, hit, on_ampm_toggle);
2805
2806 assert_eq!(
2807 read_log(&probe).seen,
2808 vec![TimePickerState {
2809 hour: 3,
2810 minute: 15,
2811 is_pm: true,
2812 is_24h: false,
2813 }],
2814 "the host was not told the new AM/PM state",
2815 );
2816 assert_eq!(update, Update::RefreshDom, "the host's verdict was swallowed");
2817 }
2818
2819 #[test]
2820 fn the_ampm_toggle_is_declined_for_a_foreign_payload() {
2821 for payload in [RefAny::new(0u8), RefAny::new(TimePickerState::default())] {
2822 let (styled, shared) = laid_out(TimePicker::create(9, 30).with_24h(false));
2823 let (hit, _) = wired_to(&styled, on_ampm_toggle as usize);
2824
2825 let (update, changes) = press(styled, &payload, hit, on_ampm_toggle);
2826
2827 assert_eq!(update, Update::DoNothing, "a foreign payload was accepted");
2828 assert!(changes.is_empty(), "a foreign payload still relabelled the toggle");
2829 assert!(!read_state(&shared).is_pm, "a foreign payload flipped the real flag");
2830 }
2831 }
2832
2833 #[test]
2834 fn the_ampm_toggle_flips_the_flag_even_in_24_hour_mode() {
2835 let (styled, shared) = laid_out(TimePicker::create(15, 0));
2840 let (hit, payload) = wired_to(&styled, on_hour_up as usize);
2841 let before = read_state(&shared).canonical_hour();
2842
2843 press(styled, &payload, hit, on_ampm_toggle);
2844
2845 assert!(read_state(&shared).is_pm, "the flag did not flip");
2846 assert_eq!(
2847 read_state(&shared).canonical_hour(),
2848 before,
2849 "a 24-hour widget's reading moved when the meaningless flag flipped",
2850 );
2851 }
2852
2853 #[test]
2854 fn the_ampm_toggle_retexts_whatever_node_it_was_told_was_hit() {
2855 let (styled, shared) = laid_out(TimePicker::create(9, 30).with_24h(false));
2859 let (_, payload) = wired_to(&styled, on_ampm_toggle as usize);
2860
2861 let (update, changes) = press(styled, &payload, node_none(), on_ampm_toggle);
2862
2863 assert_eq!(update, Update::DoNothing);
2864 assert!(read_state(&shared).is_pm, "the flag did not flip for a detached hit");
2865 assert_eq!(
2866 only_retext(&changes),
2867 (node_none(), "PM".to_string()),
2868 "the toggle did not retext the (detached) node it was handed",
2869 );
2870 }
2871
2872 #[test]
2873 fn the_rendered_ampm_label_matches_what_the_toggle_would_push() {
2874 for start_pm in [false, true] {
2878 let picker = TimePicker::create(9, 30).with_24h(false).with_pm(start_pm);
2879 let (styled, shared) = laid_out(picker);
2880 let (hit, payload) = wired_to(&styled, on_ampm_toggle as usize);
2881
2882 let (_, changes) = press(styled, &payload, hit, on_ampm_toggle);
2883 let pushed = only_retext(&changes).1;
2884
2885 let after = read_state(&shared);
2886 let rerendered = ampm_label(
2887 &TimePicker::create(after.hour, after.minute)
2888 .with_24h(false)
2889 .with_pm(after.is_pm)
2890 .dom(),
2891 );
2892 assert_eq!(
2893 rerendered.as_deref(),
2894 Some(pushed.as_str()),
2895 "the pushed label and the re-rendered label disagree (start_pm={start_pm})",
2896 );
2897 }
2898 }
2899}