1use std::f64::consts::PI;
71use std::rc::Rc;
72
73use frust::authoring::text::{FontFamily, FontWeight, TextContext, TextLayout, TextStyle};
74use frust::authoring::{
75 BoxConstraints, BuildCtx, ChangeFlags, ErasedArgCallback, EventCtx, EventResult, InputEvent,
76 LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx, TypedArgCallback, View,
77 Widget, erase_callback_arg,
78};
79use frust::{Color, Theme};
80use kurbo::{Point, Rect, Size};
81
82use super::{TimeOfDay, TimePickerMode, TimePickerStrings};
83use crate::interaction::{HapticSignal, MaterialHaptics};
84use crate::press::presses;
85
86pub const DIAL_SIZE: f64 = 256.0;
88const DIAL_KNOB_RADIUS: f64 = 20.0;
90const DIAL_CENTER_RADIUS: f64 = 4.0;
92const DIAL_RING_INSET: f64 = 4.0;
94const DIAL_HAND_WIDTH: f64 = 2.0;
96const DIAL_LABEL_FONT_SIZE: f32 = 16.0;
98const FIELD_W: f64 = 96.0;
100const FIELD_H: f64 = 80.0;
101const FIELD_MARGIN: f64 = 4.0;
103const PERIOD_W: f64 = 48.0;
105const PERIOD_H: f64 = 40.0;
106const FIELD_PERIOD_GAP: f64 = 12.0;
108const HEADER_DIAL_GAP: f64 = 24.0;
110const SMALL_RADIUS: f64 = 8.0;
114const PERIOD_BORDER_WIDTH: f64 = 1.0;
116
117const HEADER_FONT_SIZE: f32 = 45.0;
123const PERIOD_FONT_SIZE: f32 = 16.0;
125const PERIOD_FONT_WEIGHT: FontWeight = FontWeight::MEDIUM;
126
127pub const DIAL_SLOTS: usize = 12;
129
130const FALLBACK_DIAL: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
134const FALLBACK_ACCENT: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
135const FALLBACK_ON_ACCENT: Color = Color::WHITE;
136const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
137
138pub fn slot_angle(index: usize) -> f64 {
141 let step = 2.0 * PI / DIAL_SLOTS as f64;
142 -PI / 2.0 + index as f64 * step
143}
144
145pub fn dial_fraction(position: Point, dial: Size) -> Option<f64> {
152 if !position.x.is_finite()
153 || !position.y.is_finite()
154 || !dial.width.is_finite()
155 || !dial.height.is_finite()
156 || dial.width <= 0.0
157 || dial.height <= 0.0
158 {
159 return None;
160 }
161 let dx = position.x - dial.width / 2.0;
162 let dy = position.y - dial.height / 2.0;
163 let fraction = ((dy.atan2(dx) + PI / 2.0) / (2.0 * PI)).rem_euclid(1.0);
166 Some(if fraction >= 1.0 { 0.0 } else { fraction })
169}
170
171pub fn hour_from_fraction_24(fraction: f64) -> u8 {
173 let raw = (fraction * 24.0).round();
174 if !raw.is_finite() {
175 return 0;
176 }
177 (raw as i64).rem_euclid(24) as u8
178}
179
180pub fn hour_slot_from_fraction(fraction: f64) -> u8 {
183 let raw = (fraction * 12.0).round();
184 if !raw.is_finite() {
185 return 0;
186 }
187 (raw as i64).rem_euclid(12) as u8
188}
189
190pub fn minute_from_fraction(fraction: f64) -> u8 {
193 let raw = (fraction * 60.0).round();
194 if !raw.is_finite() {
195 return 0;
196 }
197 let snapped = (raw as i64).div_euclid(5) * 5;
198 snapped.rem_euclid(60) as u8
199}
200
201pub fn dial_value_at(
205 position: Point,
206 dial: Size,
207 mode: TimePickerMode,
208 use_24_hour: bool,
209 value: TimeOfDay,
210) -> Option<TimeOfDay> {
211 let fraction = dial_fraction(position, dial)?;
212 Some(match (mode, use_24_hour) {
213 (TimePickerMode::Hour, true) => value.with_hour(hour_from_fraction_24(fraction)),
214 (TimePickerMode::Hour, false) => {
215 let slot = hour_slot_from_fraction(fraction);
216 let hour_12 = if slot == 0 { 12 } else { slot };
217 TimeOfDay::from_hour_12(hour_12, value.is_pm(), value.minute())
218 }
219 (TimePickerMode::Minute, _) => value.with_minute(minute_from_fraction(fraction)),
220 })
221}
222
223pub fn dial_labels(mode: TimePickerMode, use_24_hour: bool) -> Vec<String> {
225 match (mode, use_24_hour) {
226 (TimePickerMode::Hour, true) => (0..DIAL_SLOTS).map(|i| format!("{:02}", i * 2)).collect(),
227 (TimePickerMode::Hour, false) => (0..DIAL_SLOTS)
228 .map(|i| if i == 0 { 12 } else { i }.to_string())
229 .collect(),
230 (TimePickerMode::Minute, _) => (0..DIAL_SLOTS).map(|i| format!("{:02}", i * 5)).collect(),
231 }
232}
233
234pub fn selected_index(value: TimeOfDay, mode: TimePickerMode, use_24_hour: bool) -> usize {
236 let raw = match (mode, use_24_hour) {
237 (TimePickerMode::Hour, true) => (f64::from(value.hour()) / 2.0).round() as i64,
238 (TimePickerMode::Hour, false) => i64::from(value.hour_of_12()),
239 (TimePickerMode::Minute, _) => (f64::from(value.minute()) / 5.0).round() as i64,
240 };
241 raw.rem_euclid(DIAL_SLOTS as i64) as usize
242}
243
244#[derive(Clone, Copy, Debug, PartialEq)]
252pub(super) struct DialLayout {
253 pub(super) hour_field: Rect,
254 pub(super) minute_field: Rect,
255 pub(super) colon_center: Point,
257 pub(super) period: Option<(Rect, Rect, Rect)>,
259 pub(super) ring: Rect,
260}
261
262impl DialLayout {
263 fn header_width(colon_width: f64, use_24_hour: bool) -> f64 {
265 let fields = 2.0 * (FIELD_W + 2.0 * FIELD_MARGIN) + colon_width;
266 if use_24_hour {
267 fields
268 } else {
269 fields + FIELD_PERIOD_GAP + PERIOD_W
270 }
271 }
272
273 fn content_size(colon_width: f64, use_24_hour: bool) -> Size {
275 Size::new(
276 Self::header_width(colon_width, use_24_hour).max(DIAL_SIZE),
277 FIELD_H + HEADER_DIAL_GAP + DIAL_SIZE,
278 )
279 }
280
281 fn resolve(size: Size, colon_width: f64, use_24_hour: bool) -> Self {
282 let header_w = Self::header_width(colon_width, use_24_hour);
283 let mut x = ((size.width - header_w) / 2.0).max(0.0);
284
285 x += FIELD_MARGIN;
286 let hour_field = Rect::new(x, 0.0, x + FIELD_W, FIELD_H);
287 x += FIELD_W + FIELD_MARGIN;
288
289 let colon_center = Point::new(x + colon_width / 2.0, FIELD_H / 2.0);
290 x += colon_width + FIELD_MARGIN;
291
292 let minute_field = Rect::new(x, 0.0, x + FIELD_W, FIELD_H);
293 x += FIELD_W + FIELD_MARGIN;
294
295 let period = (!use_24_hour).then(|| {
296 let left = x + FIELD_PERIOD_GAP;
297 let outline = Rect::new(left, 0.0, left + PERIOD_W, 2.0 * PERIOD_H);
298 let am = Rect::new(left, 0.0, left + PERIOD_W, PERIOD_H);
299 let pm = Rect::new(left, PERIOD_H, left + PERIOD_W, 2.0 * PERIOD_H);
300 (am, pm, outline)
301 });
302
303 let ring_left = ((size.width - DIAL_SIZE) / 2.0).max(0.0);
304 let ring_top = FIELD_H + HEADER_DIAL_GAP;
305 let ring = Rect::new(
306 ring_left,
307 ring_top,
308 ring_left + DIAL_SIZE,
309 ring_top + DIAL_SIZE,
310 );
311
312 DialLayout {
313 hour_field,
314 minute_field,
315 colon_center,
316 period,
317 ring,
318 }
319 }
320}
321
322#[derive(Clone, Copy, Debug, PartialEq, Eq)]
324enum DialRegion {
325 HourField,
326 MinuteField,
327 Am,
328 Pm,
329 Ring,
330}
331
332struct RunFamilies {
339 header: FontFamily,
341 period: FontFamily,
343 ring: FontFamily,
347}
348
349impl RunFamilies {
350 fn resolve(theme: Option<&Theme>) -> Self {
351 match theme {
352 Some(theme) => {
353 let scale = &theme.type_scale;
354 RunFamilies {
355 header: scale.display_medium.family.clone(),
356 period: scale.title_medium.family.clone(),
357 ring: scale.title_medium.family.clone(),
358 }
359 }
360 None => RunFamilies {
361 header: FontFamily::default(),
362 period: FontFamily::default(),
363 ring: FontFamily::default(),
364 },
365 }
366 }
367}
368
369struct Run {
375 content: String,
376 size: f32,
377 weight: FontWeight,
378 family: FontFamily,
380 layout: Option<TextLayout>,
381}
382
383impl Run {
384 fn new(size: f32, weight: FontWeight) -> Self {
385 Run {
386 content: String::new(),
387 size,
388 weight,
389 family: FontFamily::default(),
390 layout: None,
391 }
392 }
393
394 fn set_content(&mut self, content: &str) {
395 if self.content != content {
396 self.content = content.to_string();
397 self.layout = None;
398 }
399 }
400
401 fn style(&self) -> TextStyle {
403 TextStyle {
404 family: self.family.clone(),
405 weight: self.weight,
406 ..TextStyle::new(self.size, Color::BLACK)
407 }
408 }
409
410 fn shape(&mut self, ctx: &mut LayoutCtx, family: &FontFamily) -> Size {
412 if self.family != *family {
413 self.family = family.clone();
414 self.layout = None;
415 }
416 if let Some(layout) = &self.layout {
417 return layout.size();
418 }
419 let style = self.style();
420 let text_ctx = ctx.text_context::<TextContext>();
421 let laid = text_ctx.layout(&self.content, &style, None);
422 let size = laid.size();
423 self.layout = Some(laid);
424 size
425 }
426
427 fn paint_centered(
429 &self,
430 origin: Point,
431 center: Point,
432 color: Color,
433 scene: &mut dyn PaintScene,
434 ) {
435 let Some(layout) = &self.layout else {
436 return;
437 };
438 let size = layout.size();
439 let at = Point::new(
440 origin.x + center.x - size.width / 2.0,
441 origin.y + center.y - size.height / 2.0,
442 );
443 for mut run in layout.to_scene_runs(at) {
444 run.brush = peniko::Brush::Solid(color);
445 scene.draw_glyph_run(run);
446 }
447 }
448}
449
450pub struct TimeDialView<State: 'static> {
456 value: TimeOfDay,
457 use_24_hour: bool,
458 strings: Rc<TimePickerStrings>,
459 on_change: TypedArgCallback<State, TimeOfDay>,
460}
461
462pub fn time_dial<State: 'static, F: Fn(&mut State, TimeOfDay) + 'static>(
465 value: TimeOfDay,
466 on_change: F,
467) -> TimeDialView<State> {
468 TimeDialView {
469 value,
470 use_24_hour: false,
471 strings: Rc::new(TimePickerStrings::default()),
472 on_change: Rc::new(on_change),
473 }
474}
475
476impl<State: 'static> TimeDialView<State> {
477 pub fn use_24_hour(mut self, use_24_hour: bool) -> Self {
480 self.use_24_hour = use_24_hour;
481 self
482 }
483
484 pub fn strings(mut self, strings: TimePickerStrings) -> Self {
486 self.strings = Rc::new(strings);
487 self
488 }
489
490 pub(super) fn shared_strings(mut self, strings: Rc<TimePickerStrings>) -> Self {
494 self.strings = strings;
495 self
496 }
497}
498
499pub struct TimeDialWidget {
501 value: TimeOfDay,
502 use_24_hour: bool,
503 strings: Rc<TimePickerStrings>,
504 mode: TimePickerMode,
506 hour_run: Run,
507 colon_run: Run,
508 minute_run: Run,
509 am_run: Run,
510 pm_run: Run,
511 hour_label_runs: Vec<Run>,
518 minute_label_runs: Vec<Run>,
519 layout: DialLayout,
520 captured: Option<DialRegion>,
522 on_change: ErasedArgCallback<TimeOfDay>,
523}
524
525impl TimeDialWidget {
526 fn sync_runs(&mut self) {
530 self.hour_run
531 .set_content(&self.value.hour_label(self.use_24_hour));
532 self.colon_run.set_content(":");
533 self.minute_run.set_content(&self.value.minute_label());
534 self.am_run.set_content(&self.strings.am);
535 self.pm_run.set_content(&self.strings.pm);
536 for (mode, runs) in [
537 (TimePickerMode::Hour, &mut self.hour_label_runs),
538 (TimePickerMode::Minute, &mut self.minute_label_runs),
539 ] {
540 let labels = dial_labels(mode, self.use_24_hour);
541 if runs.len() != labels.len() {
542 *runs = labels
543 .iter()
544 .map(|_| Run::new(DIAL_LABEL_FONT_SIZE, FontWeight::MEDIUM))
545 .collect();
546 }
547 for (run, label) in runs.iter_mut().zip(labels.iter()) {
548 run.set_content(label);
549 }
550 }
551 }
552
553 fn label_runs(&self) -> &[Run] {
555 match self.mode {
556 TimePickerMode::Hour => &self.hour_label_runs,
557 TimePickerMode::Minute => &self.minute_label_runs,
558 }
559 }
560
561 fn region_at(&self, position: Point) -> Option<DialRegion> {
562 if self.layout.hour_field.contains(position) {
563 return Some(DialRegion::HourField);
564 }
565 if self.layout.minute_field.contains(position) {
566 return Some(DialRegion::MinuteField);
567 }
568 if let Some((am, pm, _)) = self.layout.period {
569 if am.contains(position) {
570 return Some(DialRegion::Am);
571 }
572 if pm.contains(position) {
573 return Some(DialRegion::Pm);
574 }
575 }
576 if self.layout.ring.contains(position) {
577 return Some(DialRegion::Ring);
578 }
579 None
580 }
581
582 fn ring_value(&self, position: Point) -> Option<TimeOfDay> {
585 let local = Point::new(
586 position.x - self.layout.ring.x0,
587 position.y - self.layout.ring.y0,
588 );
589 dial_value_at(
590 local,
591 self.layout.ring.size(),
592 self.mode,
593 self.use_24_hour,
594 self.value,
595 )
596 }
597
598 fn ring_label(&self) -> &str {
600 match self.mode {
601 TimePickerMode::Hour => &self.strings.hour_mode_announcement,
602 TimePickerMode::Minute => &self.strings.minute_mode_announcement,
603 }
604 }
605}
606
607struct DialColors {
609 dial: Color,
610 accent: Color,
611 on_accent: Color,
612 on_surface: Color,
613 field_active_bg: Color,
614 field_active_fg: Color,
615 field_idle_bg: Color,
616 field_idle_fg: Color,
617 period_selected_bg: Color,
618 period_selected_fg: Color,
619 period_idle_fg: Color,
620 outline: Color,
621}
622
623fn resolve_colors(theme: Option<&Theme>) -> DialColors {
627 match theme {
628 Some(t) => {
629 let s = t.scheme();
630 DialColors {
631 dial: s.surface_container_highest,
632 accent: s.primary,
633 on_accent: s.on_primary,
634 on_surface: s.on_surface,
635 field_active_bg: s.primary_container,
636 field_active_fg: s.on_primary_container,
637 field_idle_bg: s.surface_container_highest,
638 field_idle_fg: s.on_surface,
639 period_selected_bg: s.tertiary_container,
640 period_selected_fg: s.on_tertiary_container,
641 period_idle_fg: s.on_surface_variant,
642 outline: s.outline,
643 }
644 }
645 None => DialColors {
646 dial: FALLBACK_DIAL,
647 accent: FALLBACK_ACCENT,
648 on_accent: FALLBACK_ON_ACCENT,
649 on_surface: FALLBACK_ON_SURFACE,
650 field_active_bg: FALLBACK_DIAL,
651 field_active_fg: FALLBACK_ON_SURFACE,
652 field_idle_bg: FALLBACK_DIAL,
653 field_idle_fg: FALLBACK_ON_SURFACE,
654 period_selected_bg: FALLBACK_DIAL,
655 period_selected_fg: FALLBACK_ON_SURFACE,
656 period_idle_fg: FALLBACK_ON_SURFACE,
657 outline: FALLBACK_ON_SURFACE,
658 },
659 }
660}
661
662fn small_radius(theme: Option<&Theme>) -> f64 {
664 theme.map_or(SMALL_RADIUS, |t| t.shape.small)
665}
666
667impl<State: 'static> View<State> for TimeDialView<State> {
668 type Element = TimeDialWidget;
669
670 fn build(&self, _ctx: &mut BuildCtx<'_>) -> TimeDialWidget {
671 let mut widget = TimeDialWidget {
672 value: self.value,
673 use_24_hour: self.use_24_hour,
674 strings: self.strings.clone(),
675 mode: TimePickerMode::Hour,
676 hour_run: Run::new(HEADER_FONT_SIZE, FontWeight::REGULAR),
677 colon_run: Run::new(HEADER_FONT_SIZE, FontWeight::REGULAR),
678 minute_run: Run::new(HEADER_FONT_SIZE, FontWeight::REGULAR),
679 am_run: Run::new(PERIOD_FONT_SIZE, PERIOD_FONT_WEIGHT),
680 pm_run: Run::new(PERIOD_FONT_SIZE, PERIOD_FONT_WEIGHT),
681 hour_label_runs: Vec::new(),
682 minute_label_runs: Vec::new(),
683 layout: DialLayout::resolve(
684 DialLayout::content_size(0.0, self.use_24_hour),
685 0.0,
686 self.use_24_hour,
687 ),
688 captured: None,
689 on_change: erase_callback_arg(&self.on_change),
690 };
691 widget.sync_runs();
692 widget
693 }
694
695 fn rebuild(
696 &self,
697 prev: &Self,
698 element: &mut TimeDialWidget,
699 _ctx: &mut BuildCtx<'_>,
700 ) -> ChangeFlags {
701 element.on_change = erase_callback_arg(&self.on_change);
702 let mut flags = ChangeFlags::NONE;
703 if prev.value != self.value {
704 element.value = self.value;
705 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
709 }
710 if prev.use_24_hour != self.use_24_hour {
711 element.use_24_hour = self.use_24_hour;
712 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
713 }
714 if prev.strings != self.strings {
715 element.strings = self.strings.clone();
716 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
717 }
718 element.sync_runs();
719 flags
720 }
721
722 fn teardown(&self, _element: &mut TimeDialWidget, _ctx: &mut BuildCtx<'_>) {}
723}
724
725impl Widget for TimeDialWidget {
726 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
727 let families = RunFamilies::resolve(Theme::from_layout_ctx(ctx));
728 self.hour_run.shape(ctx, &families.header);
729 let colon = self.colon_run.shape(ctx, &families.header);
730 self.minute_run.shape(ctx, &families.header);
731 self.am_run.shape(ctx, &families.period);
732 self.pm_run.shape(ctx, &families.period);
733 for run in self
734 .hour_label_runs
735 .iter_mut()
736 .chain(self.minute_label_runs.iter_mut())
737 {
738 run.shape(ctx, &families.ring);
739 }
740 let size = bc.constrain(DialLayout::content_size(colon.width, self.use_24_hour));
741 self.layout = DialLayout::resolve(size, colon.width, self.use_24_hour);
742 size
743 }
744
745 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
746 let theme = Theme::from_paint_ctx(ctx);
747 let colors = resolve_colors(theme);
748 let radius = small_radius(theme);
749 let o = ctx.origin();
750
751 for (rect, run, active) in [
753 (
754 self.layout.hour_field,
755 &self.hour_run,
756 self.mode == TimePickerMode::Hour,
757 ),
758 (
759 self.layout.minute_field,
760 &self.minute_run,
761 self.mode == TimePickerMode::Minute,
762 ),
763 ] {
764 let (bg, fg) = if active {
765 (colors.field_active_bg, colors.field_active_fg)
766 } else {
767 (colors.field_idle_bg, colors.field_idle_fg)
768 };
769 scene.fill_rounded_rect(
770 Point::new(o.x + rect.x0, o.y + rect.y0),
771 rect.size(),
772 radius,
773 bg,
774 );
775 run.paint_centered(o, rect.center(), fg, scene);
776 }
777 self.colon_run
778 .paint_centered(o, self.layout.colon_center, colors.on_surface, scene);
779
780 if let Some((am, pm, outline)) = self.layout.period {
782 let is_pm = self.value.is_pm();
783 for (rect, run, selected) in [(am, &self.am_run, !is_pm), (pm, &self.pm_run, is_pm)] {
784 if selected {
785 scene.fill_rect(
786 Point::new(o.x + rect.x0, o.y + rect.y0),
787 rect.size(),
788 colors.period_selected_bg,
789 );
790 }
791 let fg = if selected {
792 colors.period_selected_fg
793 } else {
794 colors.period_idle_fg
795 };
796 run.paint_centered(o, rect.center(), fg, scene);
797 }
798 let (x0, y0, x1, y1) = (
803 o.x + outline.x0,
804 o.y + outline.y0,
805 o.x + outline.x1,
806 o.y + outline.y1,
807 );
808 for (a, b) in [
809 (Point::new(x0, y0), Point::new(x1, y0)),
810 (Point::new(x1, y0), Point::new(x1, y1)),
811 (Point::new(x1, y1), Point::new(x0, y1)),
812 (Point::new(x0, y1), Point::new(x0, y0)),
813 ] {
814 scene.stroke_line(a, b, PERIOD_BORDER_WIDTH, colors.outline);
815 }
816 }
817
818 let ring = self.layout.ring;
820 let center = Point::new(o.x + ring.center().x, o.y + ring.center().y);
821 let dial_radius = ring.width().min(ring.height()) / 2.0;
822 let ring_radius = (dial_radius - DIAL_KNOB_RADIUS - DIAL_RING_INSET).max(0.0);
823 scene.fill_rounded_rect(
824 Point::new(o.x + ring.x0, o.y + ring.y0),
825 ring.size(),
826 dial_radius,
827 colors.dial,
828 );
829
830 let selected = selected_index(self.value, self.mode, self.use_24_hour);
831 let angle = slot_angle(selected);
832 let knob = Point::new(
833 center.x + angle.cos() * ring_radius,
834 center.y + angle.sin() * ring_radius,
835 );
836 scene.stroke_line(center, knob, DIAL_HAND_WIDTH, colors.accent);
837 scene.fill_rounded_rect(
838 Point::new(center.x - DIAL_CENTER_RADIUS, center.y - DIAL_CENTER_RADIUS),
839 Size::new(2.0 * DIAL_CENTER_RADIUS, 2.0 * DIAL_CENTER_RADIUS),
840 DIAL_CENTER_RADIUS,
841 colors.accent,
842 );
843 scene.fill_rounded_rect(
844 Point::new(knob.x - DIAL_KNOB_RADIUS, knob.y - DIAL_KNOB_RADIUS),
845 Size::new(2.0 * DIAL_KNOB_RADIUS, 2.0 * DIAL_KNOB_RADIUS),
846 DIAL_KNOB_RADIUS,
847 colors.accent,
848 );
849
850 for (i, run) in self.label_runs().iter().enumerate() {
853 let a = slot_angle(i);
854 let at = Point::new(
855 ring.center().x + a.cos() * ring_radius,
856 ring.center().y + a.sin() * ring_radius,
857 );
858 let ink = if i == selected {
859 colors.on_accent
860 } else {
861 colors.on_surface
862 };
863 run.paint_centered(o, at, ink, scene);
864 }
865 }
866
867 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
868 let InputEvent::Pointer(p) = event else {
869 return EventResult::Ignored;
870 };
871 match p.phase {
872 PointerPhase::Down => {
873 if !presses(p) {
874 return EventResult::Ignored;
875 }
876 let Some(region) = self.region_at(p.position) else {
877 return EventResult::Ignored;
878 };
879 self.captured = Some(region);
880 ctx.capture_pointer();
881 match region {
882 DialRegion::HourField => self.mode = TimePickerMode::Hour,
883 DialRegion::MinuteField => self.mode = TimePickerMode::Minute,
884 DialRegion::Am | DialRegion::Pm => {
885 let requested = self.value.with_period(region == DialRegion::Pm);
886 (self.on_change)(ctx, requested);
887 }
888 DialRegion::Ring => {
889 MaterialHaptics::fire(HapticSignal::SliderTick);
891 if let Some(requested) = self.ring_value(p.position) {
892 (self.on_change)(ctx, requested);
893 }
894 }
895 }
896 ctx.request_redraw();
897 EventResult::Handled
898 }
899 PointerPhase::Move => {
900 if self.captured != Some(DialRegion::Ring) {
901 return if self.captured.is_some() {
904 EventResult::Handled
905 } else {
906 EventResult::Ignored
907 };
908 }
909 if let Some(requested) = self.ring_value(p.position) {
910 (self.on_change)(ctx, requested);
911 }
912 ctx.request_redraw();
913 EventResult::Handled
914 }
915 PointerPhase::Up | PointerPhase::Cancel => {
916 if self.captured.is_none() {
917 return EventResult::Ignored;
918 }
919 self.captured = None;
920 ctx.request_redraw();
921 EventResult::Handled
922 }
923 }
924 }
925
926 fn semantics(&self, ctx: &mut SemanticsCtx) {
927 let hour_label = format!("{} {}", self.strings.hour_label, self.value.hour());
928 let minute_label = format!("{} {}", self.strings.minute_label, self.value.minute());
929 let hour_active = self.mode == TimePickerMode::Hour;
930 ctx.push_container(
931 Role::Group,
932 |_| {},
933 |ctx| {
934 ctx.push_node(Role::Button, |node| {
935 node.set_label(hour_label);
936 node.set_selected(hour_active);
937 });
938 ctx.push_node(Role::Button, |node| {
939 node.set_label(minute_label);
940 node.set_selected(!hour_active);
941 });
942 if !self.use_24_hour {
943 let is_pm = self.value.is_pm();
944 ctx.push_node(Role::RadioButton, |node| {
945 node.set_label(self.strings.am.clone());
946 node.set_selected(!is_pm);
947 });
948 ctx.push_node(Role::RadioButton, |node| {
949 node.set_label(self.strings.pm.clone());
950 node.set_selected(is_pm);
951 });
952 }
953 ctx.push_node(Role::Slider, |node| {
954 node.set_label(self.ring_label().to_string());
955 node.set_numeric_value(f64::from(match self.mode {
956 TimePickerMode::Hour => self.value.hour(),
957 TimePickerMode::Minute => self.value.minute(),
958 }));
959 });
960 },
961 );
962 }
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968 use frust::authoring::{PointerButton, PointerEvent};
969 use std::any::Any;
970
971 fn at_slot(slot: usize, radius: f64) -> Point {
974 let a = slot_angle(slot);
975 Point::new(
976 DIAL_SIZE / 2.0 + a.cos() * radius,
977 DIAL_SIZE / 2.0 + a.sin() * radius,
978 )
979 }
980
981 const RING: Size = Size::new(DIAL_SIZE, DIAL_SIZE);
982
983 #[test]
984 fn cardinal_points_map_to_the_quarter_fractions() {
985 let r = 100.0;
986 for (slot, expected) in [(0usize, 0.0), (3, 0.25), (6, 0.5), (9, 0.75)] {
987 let f = dial_fraction(at_slot(slot, r), RING).expect("finite");
988 assert!(
989 (f - expected).abs() < 1e-9,
990 "slot {slot} mapped to {f}, expected {expected}"
991 );
992 }
993 }
994
995 #[test]
996 fn off_axis_points_map_to_their_own_slot_fraction() {
997 let r = 90.0;
998 for slot in 0..DIAL_SLOTS {
999 let f = dial_fraction(at_slot(slot, r), RING).expect("finite");
1000 let expected = slot as f64 / DIAL_SLOTS as f64;
1001 assert!(
1002 (f - expected).abs() < 1e-9,
1003 "slot {slot} mapped to {f}, expected {expected}"
1004 );
1005 }
1006 }
1007
1008 #[test]
1009 fn twelve_hour_ring_snaps_to_the_twelve_positions() {
1010 let noon = TimeOfDay::new(12, 30);
1011 let r = 100.0;
1012 for slot in 0..DIAL_SLOTS {
1013 let got = dial_value_at(at_slot(slot, r), RING, TimePickerMode::Hour, false, noon)
1014 .expect("finite");
1015 let expected_12 = if slot == 0 { 12 } else { slot as u8 };
1018 assert_eq!(
1019 got.hour_of_12(),
1020 expected_12,
1021 "slot {slot} should select the {expected_12} label"
1022 );
1023 assert!(got.is_pm(), "the period half is carried over, not reset");
1024 assert_eq!(got.minute(), 30, "the minute half is untouched");
1025 }
1026 }
1027
1028 #[test]
1029 fn hour_ring_is_even_labels_with_all_24_reachable() {
1030 assert_eq!(
1032 dial_labels(TimePickerMode::Hour, true),
1033 vec![
1034 "00", "02", "04", "06", "08", "10", "12", "14", "16", "18", "20", "22"
1035 ]
1036 );
1037 let mut seen = std::collections::BTreeSet::new();
1039 for step in 0..24 {
1040 let fraction = step as f64 / 24.0;
1041 seen.insert(hour_from_fraction_24(fraction));
1042 }
1043 assert_eq!(seen.len(), 24, "all 24 hours reachable in 24-hour mode");
1044
1045 assert_eq!(
1047 selected_index(TimeOfDay::new(5, 0), TimePickerMode::Hour, true),
1048 3,
1049 "05:00 parks on the `06` label (slot 3)"
1050 );
1051 assert_eq!(
1052 selected_index(TimeOfDay::new(4, 0), TimePickerMode::Hour, true),
1053 2,
1054 "04:00 parks on its own `04` label"
1055 );
1056 }
1057
1058 #[test]
1059 fn twenty_four_hour_ring_maps_each_slot_to_its_even_hour() {
1060 let base = TimeOfDay::new(0, 15);
1061 for slot in 0..DIAL_SLOTS {
1062 let got = dial_value_at(at_slot(slot, 100.0), RING, TimePickerMode::Hour, true, base)
1063 .expect("finite");
1064 assert_eq!(got.hour(), (slot * 2) as u8, "slot {slot}");
1065 assert_eq!(got.minute(), 15, "the minute half is untouched");
1066 }
1067 }
1068
1069 #[test]
1070 fn minutes_snap_to_five() {
1071 for raw in 0..60u8 {
1073 let fraction = f64::from(raw) / 60.0;
1074 assert_eq!(
1075 minute_from_fraction(fraction),
1076 (raw / 5) * 5,
1077 "minute {raw} should snap down to its 5-minute slot"
1078 );
1079 }
1080 assert_eq!(minute_from_fraction(0.999), 0);
1082 let got = dial_value_at(
1083 at_slot(1, 100.0),
1084 RING,
1085 TimePickerMode::Minute,
1086 false,
1087 TimeOfDay::new(9, 0),
1088 )
1089 .expect("finite");
1090 assert_eq!(got.minute(), 5);
1091 assert_eq!(got.hour(), 9, "the hour half is untouched");
1092 }
1093
1094 #[test]
1095 fn a_centre_press_resolves_to_three_o_clock_without_panicking() {
1096 let centre = Point::new(DIAL_SIZE / 2.0, DIAL_SIZE / 2.0);
1097 let f = dial_fraction(centre, RING).expect("a centre press is not degenerate");
1098 assert!(
1099 (f - 0.25).abs() < 1e-9,
1100 "`atan2(0, 0) == 0` puts the centre at the quarter turn, as upstream"
1101 );
1102 let got = dial_value_at(
1103 centre,
1104 RING,
1105 TimePickerMode::Hour,
1106 false,
1107 TimeOfDay::new(0, 0),
1108 )
1109 .expect("resolved");
1110 assert_eq!(got.hour_of_12(), 3);
1111 }
1112
1113 #[test]
1114 fn non_finite_input_is_refused_rather_than_propagated() {
1115 for bad in [
1116 Point::new(f64::NAN, 10.0),
1117 Point::new(10.0, f64::NAN),
1118 Point::new(f64::INFINITY, 0.0),
1119 Point::new(0.0, f64::NEG_INFINITY),
1120 ] {
1121 assert!(
1122 dial_fraction(bad, RING).is_none(),
1123 "{bad:?} must be refused"
1124 );
1125 assert!(
1126 dial_value_at(
1127 bad,
1128 RING,
1129 TimePickerMode::Minute,
1130 false,
1131 TimeOfDay::new(1, 1)
1132 )
1133 .is_none()
1134 );
1135 }
1136 for bad_box in [
1138 Size::new(0.0, 0.0),
1139 Size::new(f64::NAN, 10.0),
1140 Size::new(10.0, f64::INFINITY),
1141 Size::new(-5.0, 10.0),
1142 ] {
1143 assert!(dial_fraction(Point::new(1.0, 1.0), bad_box).is_none());
1144 }
1145 }
1146
1147 #[test]
1148 fn the_snap_helpers_never_panic_on_a_non_finite_fraction() {
1149 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1150 assert_eq!(hour_from_fraction_24(bad), 0);
1151 assert_eq!(hour_slot_from_fraction(bad), 0);
1152 assert_eq!(minute_from_fraction(bad), 0);
1153 }
1154 assert_eq!(hour_from_fraction_24(-0.5), 12);
1156 assert_eq!(minute_from_fraction(-0.5), 30);
1157 }
1158
1159 #[derive(Default)]
1164 struct App {
1165 value: TimeOfDay,
1166 changes: u32,
1167 }
1168
1169 fn mount(value: TimeOfDay, use_24_hour: bool) -> (TimeDialWidget, TextContext) {
1173 let view = time_dial::<App, _>(value, |s: &mut App, t: TimeOfDay| {
1174 s.value = t;
1175 s.changes += 1;
1176 })
1177 .use_24_hour(use_24_hour);
1178 let mut counter = 0u64;
1179 let mut widget = View::<App>::build(&view, &mut BuildCtx::new(&mut counter));
1180 let mut tcx = TextContext::new();
1181 {
1182 let mut layout_ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1183 widget.layout(
1184 &mut layout_ctx,
1185 &BoxConstraints::loose(Size::new(400.0, 800.0)),
1186 );
1187 }
1188 (widget, tcx)
1189 }
1190
1191 fn theme_with(header: &str, title: &str) -> Theme {
1196 let mut theme = crate::baseline();
1197 let scale = &mut theme.type_scale;
1198 scale.display_medium.family = FontFamily::named(header);
1199 scale.title_medium.family = FontFamily::named(title);
1200 theme
1201 }
1202
1203 fn layout_themed(widget: &mut TimeDialWidget, tcx: &mut TextContext, theme: Option<&Theme>) {
1206 let mut ctx =
1207 LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1208 widget.layout(&mut ctx, &BoxConstraints::loose(Size::new(400.0, 800.0)));
1209 }
1210
1211 fn ring_runs(widget: &TimeDialWidget) -> impl Iterator<Item = &Run> {
1213 widget
1214 .hour_label_runs
1215 .iter()
1216 .chain(widget.minute_label_runs.iter())
1217 }
1218
1219 #[test]
1220 fn layout_takes_each_run_s_family_from_its_role() {
1221 let (mut widget, mut tcx) = mount(TimeOfDay::new(10, 30), false);
1222 let mut theme = theme_with("Header Probe", "Title Probe");
1223 theme.type_scale.label_small.family = FontFamily::named("Label Small Decoy");
1226 layout_themed(&mut widget, &mut tcx, Some(&theme));
1227 let header = FontFamily::named("Header Probe");
1228 for run in [&widget.hour_run, &widget.colon_run, &widget.minute_run] {
1229 assert_eq!(run.family, header, "displayMedium");
1230 assert_eq!(run.size, HEADER_FONT_SIZE, "the token size stays");
1231 }
1232 let title = FontFamily::named("Title Probe");
1233 for run in [&widget.am_run, &widget.pm_run] {
1234 assert_eq!(run.family, title, "titleMedium");
1235 }
1236 assert_eq!(ring_runs(&widget).count(), 2 * DIAL_SLOTS);
1237 for run in ring_runs(&widget) {
1238 assert_eq!(run.family, title, "the ring labels borrow titleMedium");
1239 assert_eq!(run.size, DIAL_LABEL_FONT_SIZE, "the literal size stays");
1240 }
1241 }
1242
1243 #[test]
1244 fn without_a_theme_the_runs_keep_the_unthemed_styles() {
1245 let (widget, _tcx) = mount(TimeOfDay::new(10, 30), false);
1246 let header = TextStyle {
1247 weight: FontWeight::REGULAR,
1248 ..TextStyle::new(HEADER_FONT_SIZE, Color::BLACK)
1249 };
1250 assert_eq!(widget.hour_run.style(), header);
1251 assert_eq!(widget.colon_run.style(), header);
1252 assert_eq!(widget.minute_run.style(), header);
1253 assert_eq!(
1254 widget.am_run.style(),
1255 TextStyle {
1256 weight: PERIOD_FONT_WEIGHT,
1257 ..TextStyle::new(PERIOD_FONT_SIZE, Color::BLACK)
1258 }
1259 );
1260 for run in ring_runs(&widget) {
1261 assert_eq!(
1262 run.style(),
1263 TextStyle {
1264 weight: FontWeight::MEDIUM,
1265 ..TextStyle::new(DIAL_LABEL_FONT_SIZE, Color::BLACK)
1266 }
1267 );
1268 }
1269 }
1270
1271 #[test]
1272 fn a_theme_swap_reshapes_the_cached_runs() {
1273 let (mut widget, mut tcx) = mount(TimeOfDay::new(10, 30), false);
1274 let first = theme_with("Swap A", "Swap A");
1275 layout_themed(&mut widget, &mut tcx, Some(&first));
1276
1277 let settled = tcx.shape_cache_stats();
1279 layout_themed(&mut widget, &mut tcx, Some(&first));
1280 assert_eq!(
1281 tcx.shape_cache_stats(),
1282 settled,
1283 "an unchanged theme reshapes nothing"
1284 );
1285
1286 let second = theme_with("Swap B", "Swap B");
1287 layout_themed(&mut widget, &mut tcx, Some(&second));
1288 assert!(
1289 tcx.shape_cache_stats().shapes > settled.shapes,
1290 "a family swap must reshape the dial's runs, not serve the old face"
1291 );
1292 assert_eq!(widget.hour_run.family, FontFamily::named("Swap B"));
1293 assert_eq!(widget.am_run.family, FontFamily::named("Swap B"));
1294 }
1295
1296 fn ev(phase: PointerPhase, at: Point, button: PointerButton) -> InputEvent {
1297 InputEvent::Pointer(PointerEvent {
1298 phase,
1299 position: at,
1300 button,
1301 })
1302 }
1303
1304 fn dispatch(widget: &mut TimeDialWidget, state: &mut App, event: &InputEvent) {
1305 let state_any: &mut dyn Any = state;
1306 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, 400.0));
1307 widget.event(&mut ctx, event);
1308 }
1309
1310 fn press(widget: &mut TimeDialWidget, state: &mut App, at: Point) {
1311 dispatch(
1312 widget,
1313 state,
1314 &ev(PointerPhase::Down, at, PointerButton::Primary),
1315 );
1316 dispatch(
1317 widget,
1318 state,
1319 &ev(PointerPhase::Up, at, PointerButton::Primary),
1320 );
1321 }
1322
1323 fn ring_point(widget: &TimeDialWidget, slot: usize, radius: f64) -> Point {
1325 let local = at_slot(slot, radius);
1326 Point::new(
1327 widget.layout.ring.x0 + local.x,
1328 widget.layout.ring.y0 + local.y,
1329 )
1330 }
1331
1332 #[test]
1333 fn a_ring_press_reports_the_requested_time_and_never_self_mutates() {
1334 let (mut widget, _tcx) = mount(TimeOfDay::new(10, 30), false);
1335 let mut state = App::default();
1336 let at = ring_point(&widget, 6, 100.0);
1337 press(&mut widget, &mut state, at);
1338 assert_eq!(state.changes, 1, "one change reported");
1339 assert_eq!(state.value, TimeOfDay::new(6, 30));
1341 assert_eq!(
1342 widget.value,
1343 TimeOfDay::new(10, 30),
1344 "the dial never rewrites its own value"
1345 );
1346 }
1347
1348 #[test]
1349 fn a_drag_reports_every_position_it_crosses() {
1350 let (mut widget, _tcx) = mount(TimeOfDay::new(10, 30), false);
1351 let mut state = App::default();
1352 let points: Vec<Point> = (0..4)
1353 .map(|slot| ring_point(&widget, slot, 100.0))
1354 .collect();
1355 dispatch(
1356 &mut widget,
1357 &mut state,
1358 &ev(PointerPhase::Down, points[0], PointerButton::Primary),
1359 );
1360 for at in &points[1..] {
1361 dispatch(
1362 &mut widget,
1363 &mut state,
1364 &ev(PointerPhase::Move, *at, PointerButton::Primary),
1365 );
1366 }
1367 dispatch(
1368 &mut widget,
1369 &mut state,
1370 &ev(PointerPhase::Up, points[3], PointerButton::Primary),
1371 );
1372 assert_eq!(state.changes, 4, "one report per down/move, none on the up");
1373 assert_eq!(state.value.hour_of_12(), 3, "the last position wins");
1374 }
1375
1376 #[test]
1377 fn a_move_with_no_press_reports_nothing() {
1378 let (mut widget, _tcx) = mount(TimeOfDay::new(10, 30), false);
1379 let mut state = App::default();
1380 let at = ring_point(&widget, 6, 100.0);
1381 dispatch(
1382 &mut widget,
1383 &mut state,
1384 &ev(PointerPhase::Move, at, PointerButton::Primary),
1385 );
1386 assert_eq!(state.changes, 0);
1387 }
1388
1389 #[test]
1390 fn committing_an_hour_does_not_auto_advance_the_mode() {
1391 let (mut widget, _tcx) = mount(TimeOfDay::new(10, 30), false);
1392 let mut state = App::default();
1393 let at = ring_point(&widget, 6, 100.0);
1394 press(&mut widget, &mut state, at);
1395 assert_eq!(
1396 widget.mode,
1397 TimePickerMode::Hour,
1398 "the reference's `_handleDial` never writes `_mode`"
1399 );
1400 }
1401
1402 #[test]
1403 fn tapping_the_minute_field_switches_the_ring_to_minutes() {
1404 let (mut widget, _tcx) = mount(TimeOfDay::new(10, 30), false);
1405 let mut state = App::default();
1406 let at = widget.layout.minute_field.center();
1407 press(&mut widget, &mut state, at);
1408 assert_eq!(widget.mode, TimePickerMode::Minute);
1409 assert_eq!(state.changes, 0, "a field tap changes no value");
1410
1411 let at = ring_point(&widget, 1, 100.0);
1413 press(&mut widget, &mut state, at);
1414 assert_eq!(state.value, TimeOfDay::new(10, 5));
1415
1416 let at = widget.layout.hour_field.center();
1417 press(&mut widget, &mut state, at);
1418 assert_eq!(widget.mode, TimePickerMode::Hour, "and back again");
1419 }
1420
1421 #[test]
1422 fn the_period_cells_flip_am_pm_without_moving_the_clock_face() {
1423 let (mut widget, _tcx) = mount(TimeOfDay::new(10, 30), false);
1424 let mut state = App::default();
1425 let (am, pm, _) = widget.layout.period.expect("12-hour mode shows the toggle");
1426 press(&mut widget, &mut state, pm.center());
1427 assert_eq!(state.value, TimeOfDay::new(22, 30), "10:30 AM -> 10:30 PM");
1428
1429 let (mut widget, _tcx) = mount(TimeOfDay::new(22, 30), false);
1430 let (am2, _, _) = widget.layout.period.expect("toggle");
1431 assert_eq!(am, am2, "the toggle sits in the same place either way");
1432 press(&mut widget, &mut state, am2.center());
1433 assert_eq!(state.value, TimeOfDay::new(10, 30), "and back again");
1434 }
1435
1436 #[test]
1437 fn a_secondary_press_starts_nothing() {
1438 let (mut widget, _tcx) = mount(TimeOfDay::new(10, 30), false);
1439 let mut state = App::default();
1440 let at = ring_point(&widget, 6, 100.0);
1441 dispatch(
1442 &mut widget,
1443 &mut state,
1444 &ev(PointerPhase::Down, at, PointerButton::Secondary),
1445 );
1446 assert_eq!(state.changes, 0);
1447 assert!(widget.captured.is_none(), "and no capture to wedge on");
1448 }
1449
1450 #[test]
1451 fn a_press_outside_every_region_is_ignored() {
1452 let (mut widget, _tcx) = mount(TimeOfDay::new(10, 30), false);
1453 let mut state = App::default();
1454 press(&mut widget, &mut state, Point::new(2.0, 2.0));
1455 assert_eq!(state.changes, 0);
1456 }
1457
1458 #[test]
1459 fn a_cancel_disarms_the_drag() {
1460 let (mut widget, _tcx) = mount(TimeOfDay::new(10, 30), false);
1461 let mut state = App::default();
1462 let at = ring_point(&widget, 6, 100.0);
1463 dispatch(
1464 &mut widget,
1465 &mut state,
1466 &ev(PointerPhase::Down, at, PointerButton::Primary),
1467 );
1468 assert!(widget.captured.is_some());
1469 dispatch(
1470 &mut widget,
1471 &mut state,
1472 &ev(PointerPhase::Cancel, at, PointerButton::Primary),
1473 );
1474 assert!(widget.captured.is_none());
1475 let before = state.changes;
1476 let elsewhere = ring_point(&widget, 9, 100.0);
1477 dispatch(
1478 &mut widget,
1479 &mut state,
1480 &ev(PointerPhase::Move, elsewhere, PointerButton::Primary),
1481 );
1482 assert_eq!(state.changes, before, "a disarmed drag reports nothing");
1483 }
1484
1485 #[test]
1486 fn the_twenty_four_hour_dial_drops_the_period_toggle() {
1487 let (widget, _tcx) = mount(TimeOfDay::new(18, 45), true);
1488 assert!(widget.layout.period.is_none());
1489 assert_eq!(widget.hour_label_runs.len(), DIAL_SLOTS);
1490 assert_eq!(widget.hour_label_runs[1].content, "02");
1491 assert_eq!(widget.minute_label_runs[1].content, "05");
1492 }
1493
1494 #[test]
1498 fn switching_mode_needs_no_reshaping() {
1499 let (mut widget, _tcx) = mount(TimeOfDay::new(10, 30), false);
1500 let mut state = App::default();
1501 assert!(
1502 widget.label_runs().iter().all(|r| r.layout.is_some()),
1503 "the hour ring is shaped after layout"
1504 );
1505 let at = widget.layout.minute_field.center();
1506 press(&mut widget, &mut state, at);
1507 assert_eq!(widget.mode, TimePickerMode::Minute);
1508 assert!(
1509 widget.label_runs().iter().all(|r| r.layout.is_some()),
1510 "and so is the minute ring, with no layout pass in between"
1511 );
1512 assert_eq!(widget.label_runs()[1].content, "05");
1513 }
1514
1515 #[test]
1516 fn a_value_change_asks_for_a_layout_pass_so_the_header_digits_reshape() {
1517 let a = time_dial::<App, _>(TimeOfDay::new(10, 30), |_: &mut App, _| {});
1518 let b = time_dial::<App, _>(TimeOfDay::new(11, 30), |_: &mut App, _| {});
1519 let mut counter = 0u64;
1520 let mut widget = View::<App>::build(&a, &mut BuildCtx::new(&mut counter));
1521 let flags = View::<App>::rebuild(&b, &a, &mut widget, &mut BuildCtx::new(&mut counter));
1522 assert!(
1523 flags.contains(ChangeFlags::LAYOUT),
1524 "a cleared shaped run can only be re-shaped from `layout`"
1525 );
1526 }
1527
1528 #[test]
1529 fn the_dial_body_is_the_reference_s_three_hundred_and_sixty() {
1530 let (widget, _tcx) = mount(TimeOfDay::new(10, 30), false);
1531 assert_eq!(widget.layout.ring.y1, 360.0);
1533 assert_eq!(widget.layout.ring.width(), DIAL_SIZE);
1534 }
1535
1536 #[test]
1537 fn semantics_reports_the_fields_the_period_and_the_ring() {
1538 fn logic(state: &mut App) -> TimeDialView<App> {
1539 time_dial(state.value, |s: &mut App, t: TimeOfDay| s.value = t)
1540 }
1541 let mut root: frust_core::RenderRoot<App, TimeDialView<App>> =
1542 frust_core::RenderRoot::new();
1543 let mut state = App {
1544 value: TimeOfDay::new(10, 30),
1545 changes: 0,
1546 };
1547 root.rebuild(&mut logic, &mut state);
1548 let mut tcx = TextContext::new();
1549 root.layout_with_text(Size::new(400.0, 800.0), &mut tcx as &mut dyn Any);
1550 let update = root.semantics();
1551
1552 let buttons: Vec<_> = update
1553 .nodes
1554 .iter()
1555 .filter(|(_, n)| n.role() == Role::Button)
1556 .collect();
1557 assert_eq!(buttons.len(), 2, "an hour field and a minute field");
1558 assert_eq!(buttons[0].1.is_selected(), Some(true), "hour is active");
1559 let radios: Vec<_> = update
1560 .nodes
1561 .iter()
1562 .filter(|(_, n)| n.role() == Role::RadioButton)
1563 .collect();
1564 assert_eq!(radios.len(), 2, "AM and PM");
1565 assert!(
1566 update
1567 .nodes
1568 .iter()
1569 .any(|(_, n)| n.role() == Role::Slider && n.label() == Some("Select hours"))
1570 );
1571 }
1572}