Skip to main content

frust_material/time_picker/
dial.rs

1// Ported from `material_3_expressive` v1.0.8 (MIT, © 2026 Paa Developments;
2// `tmp/material_3_expressive/lib/components/time_pickers/components/` —
3// `m3e_dial_time_picker.dart` (`M3EDialTimePicker`) and
4// `m3e_time_dial_painter.dart` (`M3ETimeDialPainter`), plus
5// `styles/m3e_time_picker_theme.dart`'s dial metrics, retrieved 2026-08-20).
6// Upstream: <https://github.com/paadevelopments/material_3_expressive>
7//
8// Porting decisions (each documented with its reference site below): the
9// header fields, period toggle and ring are painted by this one widget instead
10// of a `Row` of `M3ETappable`s over a `CustomPaint`, so the whole dial keeps a
11// single hit-test/paint pass; the `FittedBox(scaleDown)` wrapper is dropped in
12// favour of `bc.constrain`; and the reference's `_mode` `setState` becomes
13// widget-local state, since a `View` has no `setState`.
14
15//! The M3E clock **dial**: an hour/minute ring with a draggable hand, above a
16//! header of hour/minute fields and (in 12-hour mode) an AM/PM selector.
17//!
18//! [`time_dial`] is a controlled component: it reports the *requested*
19//! [`TimeOfDay`] through `on_change` and never rewrites its own `value`
20//! (`docs/CODE_STANDARDS.md`'s Interaction Semantics). The one piece of state
21//! it does own is which unit the ring is editing ([`TimePickerMode`]) — the
22//! reference's own `_M3EDialTimePickerState._mode`, ephemeral view state in
23//! exactly the way `pressed` is.
24//!
25//! # Angle ↔ value, and the snap rules (cited)
26//!
27//! [`dial_fraction`] is `_handleDial`'s
28//! `((atan2(dy, dx) + pi/2) / 2pi) % 1` verbatim: a `0..1` sweep starting at
29//! the 12 o'clock slot and running clockwise. Each mode then maps that
30//! fraction with the reference's own rounding:
31//!
32//! | Mode | Reference | Port |
33//! |---|---|---|
34//! | hour, 24h | `(fraction * 24).round() % 24` | [`hour_from_fraction_24`] |
35//! | hour, 12h | `(fraction * 12).round() % 12`, slot `0` ⇒ 12 | [`hour_slot_from_fraction`] |
36//! | minute | `((fraction * 60).round() ~/ 5) * 5 % 60` | [`minute_from_fraction`] |
37//!
38//! So **minutes snap to 5**, 12-hour hours snap to the 12 ring positions, and
39//! 24-hour hours snap to all 24 — see the next section.
40//!
41//! # 24-hour mode is one ring of even hours, not two rings
42//!
43//! The reference draws a **single** 12-label ring in 24-hour mode
44//! (`_dialLabels`: `00, 02, 04 … 22`) while its angle map resolves all 24
45//! hours (`(fraction * 24).round()`), so an odd hour is reachable by pointing
46//! *between* two labels and the hand then parks on the nearer even one
47//! (`_selectedIndex`: `(hour / 2).round() % 12`). It is **not** Material's own
48//! inner/outer dual ring, and this port does not add one — porting the
49//! reference is the contract here. [`dial_labels`]/[`selected_index`] carry
50//! that shape, and `hour_ring_is_even_labels_with_all_24_reachable` pins it.
51//!
52//! # Degenerate pointer input
53//!
54//! A non-finite position (or a non-finite/empty dial box) is refused outright:
55//! [`dial_fraction`] returns `None` and the widget reports nothing, so a NaN
56//! can never reach the `% 24` / `~/ 5` arithmetic below (the same discipline
57//! `crate::slider`'s `ValueSpec::snap` documents). A press on the exact centre
58//! is *not* refused — it resolves to the 3 o'clock slot, because `atan2(0, 0)`
59//! is `0` in both Dart and Rust, which is what the reference does too. Both
60//! are pinned by test.
61//!
62//! # No hour → minute auto-advance
63//!
64//! Committing an hour on the ring does **not** switch the ring to minutes in
65//! the reference: `_handleDial` writes the value and never touches `_mode`,
66//! which only ever changes from a header-field tap (`_buildField`'s `onTap`).
67//! This port matches, and `committing_an_hour_does_not_auto_advance_the_mode`
68//! pins it, so a later divergence is a deliberate change rather than a drift.
69
70use 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
86/// Dial diameter (`M3ETimePickerTheme.dialSize`).
87pub const DIAL_SIZE: f64 = 256.0;
88/// Radius of the selection knob riding the hand (`dialKnobRadius`).
89const DIAL_KNOB_RADIUS: f64 = 20.0;
90/// Radius of the hand's centre pivot dot (`dialCenterRadius`).
91const DIAL_CENTER_RADIUS: f64 = 4.0;
92/// Gap between the knob's outer edge and the dial's rim (`dialRingInset`).
93const DIAL_RING_INSET: f64 = 4.0;
94/// Stroke width of the hand (`dialHandWidth`).
95const DIAL_HAND_WIDTH: f64 = 2.0;
96/// Font size of a ring label (`dialLabelFontSize`).
97const DIAL_LABEL_FONT_SIZE: f32 = 16.0;
98/// One hour/minute header field's box (`fieldSize`).
99const FIELD_W: f64 = 96.0;
100const FIELD_H: f64 = 80.0;
101/// Horizontal margin flanking each header field (`fieldMargin`).
102const FIELD_MARGIN: f64 = 4.0;
103/// One AM/PM cell's box (`periodOptionSize`).
104const PERIOD_W: f64 = 48.0;
105const PERIOD_H: f64 = 40.0;
106/// Gap between the minute field and the period toggle (`fieldPeriodGap`).
107const FIELD_PERIOD_GAP: f64 = 12.0;
108/// Gap between the header row and the dial (`headerDialGap`).
109const HEADER_DIAL_GAP: f64 = 24.0;
110/// The corner radius of a header field and of the period toggle
111/// (`M3EShapes.radiusSmall`, `m3e_dimensions.dart:68`) — the theme's `small`
112/// token when one is available.
113const SMALL_RADIUS: f64 = 8.0;
114/// Outline stroke width of the period toggle (Flutter's `Border.all` default).
115const PERIOD_BORDER_WIDTH: f64 = 1.0;
116
117/// The header field/colon type-scale token (M3 `displayMedium`, 45/52 w400 —
118/// `crate::tokens::type_scale`'s `display_medium`). This size and the period
119/// token below are the roles' literals; each run's *family* is read off the
120/// live [`Theme`]'s type scale in the layout pass ([`Theme::from_layout_ctx`] —
121/// see [`RunFamilies::resolve`]).
122const HEADER_FONT_SIZE: f32 = 45.0;
123/// The AM/PM cell's type-scale token (M3 `titleMedium`, 16/24 w500).
124const PERIOD_FONT_SIZE: f32 = 16.0;
125const PERIOD_FONT_WEIGHT: FontWeight = FontWeight::MEDIUM;
126
127/// Number of labels drawn around the ring (`_dialLabels` always yields 12).
128pub const DIAL_SLOTS: usize = 12;
129
130/// Unthemed fallbacks, used only when no [`Theme`] reaches `paint` (a bare
131/// `RenderRoot` in a test). Values are the M3 light-scheme roles the themed
132/// path resolves.
133const 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
138/// The ring's angle for slot `index` — `_angleFor`: `-pi/2 + index * step`,
139/// i.e. slot `0` at 12 o'clock, running clockwise.
140pub 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
145/// The `0..1` clockwise-from-12-o'clock sweep a pointer at `position` lands on
146/// inside a `dial`-sized box, or `None` for degenerate input.
147///
148/// `_handleDial`'s `((atan2(dy, dx) + pi/2) / (2 * pi)) % 1`, with a finiteness
149/// guard the Dart has no need for (see the [module docs](self)' *Degenerate
150/// pointer input*). `None` means "report nothing", never a substituted value.
151pub 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    // `atan2(0, 0) == 0` — an exact-centre press resolves to the quarter turn,
164    // as upstream. `rem_euclid` is Dart's `%` on doubles (always non-negative).
165    let fraction = ((dy.atan2(dx) + PI / 2.0) / (2.0 * PI)).rem_euclid(1.0);
166    // `rem_euclid` can return exactly `1.0` for a fraction just below zero
167    // after rounding; fold it back so every caller sees a half-open range.
168    Some(if fraction >= 1.0 { 0.0 } else { fraction })
169}
170
171/// 24-hour-mode hour for a ring `fraction` — `(fraction * 24).round() % 24`.
172pub 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
180/// 12-hour-mode ring slot for a `fraction` — `(fraction * 12).round() % 12`.
181/// Slot `0` is the "12" label (`_setHour`'s `slot == 0 ? 12 : slot`).
182pub 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
190/// Minute for a ring `fraction` — `((fraction * 60).round() ~/ 5) * 5 % 60`,
191/// i.e. snapped down to the enclosing 5-minute slot.
192pub 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
201/// The time `position` selects inside a `dial`-sized ring box, given the unit
202/// being edited and the current `value` (which supplies the untouched half of
203/// the pair and, in 12-hour mode, the AM/PM half). `None` for degenerate input.
204pub 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
223/// The 12 ring labels for a mode — `_dialLabels`.
224pub 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
234/// Which ring slot the hand parks on — `_selectedIndex`.
235pub 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// ============================================================================
245// Geometry
246// ============================================================================
247
248/// The header/ring boxes the widget hit-tests and paints against, in
249/// widget-local coordinates. Resolved from the laid-out size so a wider box
250/// centres its content rather than pinning it left.
251#[derive(Clone, Copy, Debug, PartialEq)]
252pub(super) struct DialLayout {
253    pub(super) hour_field: Rect,
254    pub(super) minute_field: Rect,
255    /// Centre of the ":" separator glyph run.
256    pub(super) colon_center: Point,
257    /// AM cell, PM cell, and the toggle's outlined box — 12-hour mode only.
258    pub(super) period: Option<(Rect, Rect, Rect)>,
259    pub(super) ring: Rect,
260}
261
262impl DialLayout {
263    /// Header content width for a `colon_width`-wide separator.
264    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    /// The intrinsic content size: header band, gap, ring.
274    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/// The region a press landed in.
323#[derive(Clone, Copy, Debug, PartialEq, Eq)]
324enum DialRegion {
325    HourField,
326    MinuteField,
327    Am,
328    Pm,
329    Ring,
330}
331
332// ============================================================================
333// Shaped text runs
334// ============================================================================
335
336/// The families one layout pass resolves for the dial's runs: the live
337/// theme's own for each run's role, or the platform system UI family unthemed.
338struct RunFamilies {
339    /// The hour/colon/minute header fields — `displayMedium`.
340    header: FontFamily,
341    /// The AM/PM cells — `titleMedium`.
342    period: FontFamily,
343    /// The ring labels. `dialLabelFontSize` is not a type-scale role; its
344    /// 16px Medium is exactly `titleMedium`'s, so it borrows that role's
345    /// family.
346    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
369/// A lazily-shaped, paint-time-rebrushed text run — the same idiom
370/// `crate::badge`'s `LabelRun` uses, so the ink under the hand can flip
371/// without reshaping. Size and weight are the run's token literals; the cache
372/// is keyed on the content *and* the family, so a theme swap that changes the
373/// family reshapes instead of serving the old face.
374struct Run {
375    content: String,
376    size: f32,
377    weight: FontWeight,
378    /// The family the cached layout was shaped in.
379    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    /// The style the run shapes with.
402    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    /// Shape (or reuse) the run in `family`, returning its measured size.
411    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    /// Paint centred on `center`, in `color`.
428    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
450// ============================================================================
451// View / Widget
452// ============================================================================
453
454/// A declarative clock dial. See the [module docs](self).
455pub struct TimeDialView<State: 'static> {
456    value: TimeOfDay,
457    use_24_hour: bool,
458    strings: Rc<TimePickerStrings>,
459    on_change: TypedArgCallback<State, TimeOfDay>,
460}
461
462/// Create a controlled clock dial showing `value` and reporting each requested
463/// time through `on_change(state, requested)`.
464pub 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    /// Show a 24-hour ring and drop the AM/PM selector
478    /// (`alwaysUse24HourFormat`). See the [module docs](self)' *24-hour mode*.
479    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    /// Override the AM/PM labels and the ring's accessibility announcements.
485    pub fn strings(mut self, strings: TimePickerStrings) -> Self {
486        self.strings = Rc::new(strings);
487        self
488    }
489
490    /// Share an already-`Rc`'d string bundle (used by
491    /// [`crate::time_picker::time_picker`], which owns one for the whole
492    /// dialog).
493    pub(super) fn shared_strings(mut self, strings: Rc<TimePickerStrings>) -> Self {
494        self.strings = strings;
495        self
496    }
497}
498
499/// The retained widget for a [`TimeDialView`].
500pub struct TimeDialWidget {
501    value: TimeOfDay,
502    use_24_hour: bool,
503    strings: Rc<TimePickerStrings>,
504    /// Which unit the ring edits — widget-local, the reference's `_mode`.
505    mode: TimePickerMode,
506    hour_run: Run,
507    colon_run: Run,
508    minute_run: Run,
509    am_run: Run,
510    pm_run: Run,
511    /// **Both** rings are shaped, and `paint` picks by [`Self::mode`]: a mode
512    /// switch happens inside `event`, which has no `request_layout` seam
513    /// (only `PaintCtx` has one), so a single mode-swapped run list would
514    /// paint stale — or blank — digits until some unrelated pass relaid it
515    /// out. Shaping 24 two-glyph runs instead of 12 makes the switch a pure
516    /// repaint.
517    hour_label_runs: Vec<Run>,
518    minute_label_runs: Vec<Run>,
519    layout: DialLayout,
520    /// The region a live capture started in.
521    captured: Option<DialRegion>,
522    on_change: ErasedArgCallback<TimeOfDay>,
523}
524
525impl TimeDialWidget {
526    /// Re-seed every run's content from the current value/format. Every caller
527    /// is on a path that also raises `ChangeFlags::LAYOUT`, since a cleared run
528    /// can only be re-shaped from `layout`.
529    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    /// The ring labels for the unit currently being edited.
554    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    /// Map a widget-local pointer position inside the ring to a requested
583    /// time. `None` when the input is degenerate.
584    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    /// The ring's semantic label for the unit currently being edited.
599    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
607/// Resolved ink for one paint pass.
608struct 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
623/// Resolve the dial's ink from `theme` — `M3ETimePickerTheme`'s colour
624/// getters (`containerColor`/`fieldBackgroundColor`/`fieldForegroundColor`/
625/// `periodOption*Color`) plus `_buildDial`'s scheme reads.
626fn 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
662/// The theme's `small` corner token, or the reference's literal 8dp.
663fn 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            // `LAYOUT` as well as `PAINT`: the header's digits are shaped text
706            // whose cached run `sync_runs` below clears, and only `layout` can
707            // re-shape it (see [`TimeDialWidget::sync_runs`]).
708            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        // --- header fields (`_buildField`) ---
752        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        // --- AM/PM toggle (`_buildPeriodToggle`) ---
781        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            // The outline is drawn as four hairlines rather than a stroked
799            // rounded rect: `PaintScene` has no rounded-rect *stroke*, and the
800            // toggle's own fills already carry the corner shape via the clip
801            // its container applies.
802            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        // --- ring (`M3ETimeDialPainter.paint`) ---
819        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        // Labels last, so the selected one reads over the knob in `onPrimary`
851        // (`_paintLabel`'s contrast flip).
852        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                        // `onTapDown`: haptic first, then the tap's effect.
890                        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                    // A header/period press tracks no drag (the reference wires
902                    // `onPanUpdate` to the ring alone).
903                    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    /// A pointer position on the ring at the given clock `slot` (0 = 12
972    /// o'clock, clockwise), in ring-local coordinates.
973    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            // Slot 0 is the "12" label; the AM/PM half is carried over from
1016            // the current value (PM here), so slot 0 stays 12:00 PM.
1017            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        // The 24-hour ring draws 12 even-hour labels ...
1031        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        // ... but every one of the 24 hours is reachable by angle.
1038        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        // And the hand parks on the nearer even label for an odd hour.
1046        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        // Every fraction inside a 5-minute slot resolves to that slot's floor.
1072        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        // The wrap-around case: a fraction that rounds to 60 folds to 0.
1081        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        // A degenerate dial box is refused too.
1137        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        // A wildly out-of-range fraction wraps instead of overflowing.
1155        assert_eq!(hour_from_fraction_24(-0.5), 12);
1156        assert_eq!(minute_from_fraction(-0.5), 30);
1157    }
1158
1159    // ------------------------------------------------------------------
1160    // Widget-level behaviour
1161    // ------------------------------------------------------------------
1162
1163    #[derive(Default)]
1164    struct App {
1165        value: TimeOfDay,
1166        changes: u32,
1167    }
1168
1169    /// Build the dial widget directly (the harness `crate::switch`'s own tests
1170    /// use) and lay it out against a text context, so the resolved geometry is
1171    /// available to hit-test against.
1172    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    // ---- typeface: each run's family follows its role on the live theme ----
1192
1193    /// A baseline theme whose `displayMedium`/`titleMedium` roles name
1194    /// `header`/`title`.
1195    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    /// Re-lay `widget` out against `tcx`, threading `theme` the way the render
1204    /// root does.
1205    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    /// Every ring-label run, both rings.
1212    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        // A decoy on a role the dial must not read: the ring labels borrow
1224        // `titleMedium` (the role whose 16px Medium they match), not this one.
1225        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        // Control: the same theme again reuses every cached run outright.
1278        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    /// A widget-local point on the ring at clock `slot`.
1324    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        // Slot 6 on the 12-hour ring is the "6" label; 10:30 is AM, so 06:30.
1340        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        // ... and the ring now snaps minutes, not hours.
1412        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    /// A mode switch happens inside `event`, which cannot ask for a layout
1495    /// pass, so both rings must already be shaped when the switch lands — see
1496    /// [`TimeDialWidget::hour_label_runs`].
1497    #[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        // 80 header + 24 gap + 256 ring — `dialDialogBodyHeight`.
1532        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}