Skip to main content

rosace_widgets/tree/
time_picker.rs

1//! `TimePicker` — an Android-Material **clock-dial** hour:minute picker
2//! (D115/Phase 32; dial per `.steering/PICKERS_SPEC.md`, 2026-07-24).
3//!
4//! A circular dial with an animated hand + thumb, an AM/PM toggle, and a
5//! header whose hour/minute switch which the dial edits. No seconds (removed
6//! by design — see the spec). Every component is stylable via a builder and
7//! theme-defaulted. Controlled: the app owns `value` (+ optionally the edit
8//! unit) and gets changes back through callbacks.
9
10use std::sync::Arc;
11use rosace_core::types::{Point, Rect, Size};
12use rosace_render::Color;
13use super::{LayoutCtx, PaintCtx, Widget, vcenter_text_y};
14use super::container::draw_rounded_rect_pub;
15
16/// A plain wall-clock time — hour (0-23) and minute (0-59). No seconds.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub struct SimpleTime {
19    pub hour: u8,
20    pub minute: u8,
21}
22
23impl SimpleTime {
24    pub fn new(hour: u8, minute: u8) -> Self { Self { hour: hour.min(23), minute: minute.min(59) } }
25    /// `(1-12, is_pm)` — the 12-hour display form.
26    pub fn hour_12(self) -> (u8, bool) {
27        let is_pm = self.hour >= 12;
28        (match self.hour % 12 { 0 => 12, h => h }, is_pm)
29    }
30    pub fn with_hour_12(self, h12: u8, is_pm: bool) -> Self {
31        let h12 = h12.clamp(1, 12);
32        let hour = match (h12, is_pm) { (12, false) => 0, (12, true) => 12, (h, false) => h, (h, true) => h + 12 };
33        Self::new(hour, self.minute)
34    }
35}
36
37/// Which unit the dial currently edits.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum TimeUnit { Hour, Minute }
40
41const DIAL_D: f32 = 240.0;
42const HEADER_H: f32 = 64.0;
43const PAD: f32 = 16.0;
44
45/// A Material clock-dial time picker.
46pub struct TimePicker {
47    value: SimpleTime,
48    editing: TimeUnit,
49    minute_step: u8,
50    use_24h: bool,
51    accent: Option<Color>,
52    dial_color: Option<Color>,
53    hand_color: Option<Color>,
54    thumb_color: Option<Color>,
55    number_color: Option<Color>,
56    selected_number_color: Option<Color>,
57    on_change: Option<Arc<dyn Fn(SimpleTime) + Send + Sync>>,
58    on_unit_change: Option<Arc<dyn Fn(TimeUnit) + Send + Sync>>,
59}
60
61impl TimePicker {
62    pub fn new(value: SimpleTime) -> Self {
63        Self {
64            value, editing: TimeUnit::Hour, minute_step: 1, use_24h: false,
65            accent: None, dial_color: None, hand_color: None, thumb_color: None,
66            number_color: None, selected_number_color: None,
67            on_change: None, on_unit_change: None,
68        }
69    }
70    /// Which unit the dial edits (controlled; pair with `.on_unit_change`).
71    pub fn editing(mut self, u: TimeUnit) -> Self { self.editing = u; self }
72    /// 24-hour clock: two concentric rings (00–11 outer, 12–23 inner), no
73    /// AM/PM. The distance of a tap from the centre picks the ring.
74    pub fn use_24h(mut self) -> Self { self.use_24h = true; self }
75    pub fn minute_step(mut self, s: u8) -> Self { self.minute_step = s.max(1); self }
76    pub fn accent(mut self, c: Color) -> Self { self.accent = Some(c); self }
77    /// Dial face fill (default: a faint `surface_variant`).
78    pub fn dial_color(mut self, c: Color) -> Self { self.dial_color = Some(c); self }
79    /// The hand line color (default: accent).
80    pub fn hand_color(mut self, c: Color) -> Self { self.hand_color = Some(c); self }
81    /// The thumb disc at the hand's tip (default: accent).
82    pub fn thumb_color(mut self, c: Color) -> Self { self.thumb_color = Some(c); self }
83    /// Unselected number color (default: `on_surface`).
84    pub fn number_color(mut self, c: Color) -> Self { self.number_color = Some(c); self }
85    /// The number sitting on the thumb (default: bright/`on_primary`).
86    pub fn selected_number_color(mut self, c: Color) -> Self { self.selected_number_color = Some(c); self }
87    pub fn on_change(mut self, f: impl Fn(SimpleTime) + Send + Sync + 'static) -> Self {
88        self.on_change = Some(Arc::new(f)); self
89    }
90    pub fn on_unit_change(mut self, f: impl Fn(TimeUnit) + Send + Sync + 'static) -> Self {
91        self.on_unit_change = Some(Arc::new(f)); self
92    }
93
94    fn target_angle(&self) -> f32 {
95        match self.editing {
96            TimeUnit::Hour => (self.value.hour_12().0 as f32 % 12.0) * 30.0,
97            TimeUnit::Minute => self.value.minute as f32 * 6.0,
98        }
99    }
100}
101
102fn with_alpha(c: Color, a: f32) -> Color {
103    Color::rgba(c.r, c.g, c.b, (a.clamp(0.0, 1.0) * 255.0).round() as u8)
104}
105/// Point on a circle at `deg` clockwise from 12-o'clock.
106fn on_circle(cx: f32, cy: f32, r: f32, deg: f32) -> (f32, f32) {
107    let a = deg.to_radians();
108    (cx + r * a.sin(), cy - r * a.cos())
109}
110
111impl Widget for TimePicker {
112    fn layout(&self, _ctx: &LayoutCtx) -> Size {
113        Size { width: DIAL_D + PAD * 2.0, height: HEADER_H + DIAL_D + PAD * 2.0 }
114    }
115
116    fn paint(&self, ctx: &mut PaintCtx) {
117        let colors = ctx.theme.colors.clone();
118        let accent = self.accent.unwrap_or_else(|| ctx.tc(colors.primary));
119        let on_surface = ctx.tc(colors.on_surface);
120        let dial_fill = self.dial_color.unwrap_or_else(|| with_alpha(ctx.tc(colors.surface_variant), 0.55));
121        let hand_c = self.hand_color.unwrap_or(accent);
122        let thumb_c = self.thumb_color.unwrap_or(accent);
123        let num_c = self.number_color.unwrap_or(on_surface);
124        let sel_num_c = self.selected_number_color.unwrap_or(Color::rgb(252, 252, 255));
125
126        let r = ctx.rect;
127        let (h12, is_pm) = self.value.hour_12();
128
129        // ── Header: HH : MM  (tappable to switch unit) + AM/PM ───────────────
130        let hy = r.origin.y + PAD;
131        let big = 34.0;
132        let hh = if self.use_24h { format!("{:02}", self.value.hour) } else { format!("{h12:02}") };
133        let mm = format!("{:02}", self.value.minute);
134        let hw = ctx.font.measure_text(&hh, big);
135        let cw = ctx.font.measure_text(":", big);
136        let mw = ctx.font.measure_text(&mm, big);
137        let group_w = hw + 8.0 + cw + 8.0 + mw;
138        let hx = r.origin.x + (r.size.width - group_w) / 2.0 - 10.0;
139        let hour_sel = matches!(self.editing, TimeUnit::Hour);
140        ctx.draw_text_at(&hh, Point { x: hx, y: vcenter_text_y(hy, big, ctx.font, big) },
141            if hour_sel { accent } else { with_alpha(on_surface, 0.55) }, big);
142        ctx.draw_text_at(":", Point { x: hx + hw + 8.0, y: vcenter_text_y(hy, big, ctx.font, big) }, with_alpha(on_surface, 0.55), big);
143        ctx.draw_text_at(&mm, Point { x: hx + hw + 8.0 + cw + 8.0, y: vcenter_text_y(hy, big, ctx.font, big) },
144            if !hour_sel { accent } else { with_alpha(on_surface, 0.55) }, big);
145        let hour_hit = Rect { origin: Point { x: hx - 4.0, y: hy }, size: Size { width: hw + 8.0, height: big } };
146        let min_hit = Rect { origin: Point { x: hx + hw + 8.0 + cw + 4.0, y: hy }, size: Size { width: mw + 8.0, height: big } };
147        if let Some(uc) = &self.on_unit_change {
148            { let uc = uc.clone(); ctx.child(hour_hit).register_hit(Arc::new(move || uc(TimeUnit::Hour))); }
149            { let uc = uc.clone(); ctx.child(min_hit).register_hit(Arc::new(move || uc(TimeUnit::Minute))); }
150        }
151
152        // AM/PM pill (top-right) — 12-hour mode only.
153        let ampm_label = if is_pm { "PM" } else { "AM" };
154        if !self.use_24h {
155            let ap_w = 44.0;
156            let ap_rect = Rect { origin: Point { x: r.origin.x + r.size.width - ap_w - PAD, y: hy + 4.0 }, size: Size { width: ap_w, height: 30.0 } };
157            draw_rounded_rect_pub(ctx, ap_rect, with_alpha(accent, 0.9), 8.0);
158            let apw = ctx.font.measure_text(ampm_label, 14.0);
159            ctx.draw_text_at(ampm_label, Point { x: ap_rect.origin.x + (ap_w - apw) / 2.0, y: vcenter_text_y(ap_rect.origin.y, 30.0, ctx.font, 14.0) }, sel_num_c, 14.0);
160            if let Some(oc) = &self.on_change {
161                let oc = oc.clone(); let v = self.value;
162                ctx.child(ap_rect).register_hit(Arc::new(move || { let (h, pm) = v.hour_12(); oc(v.with_hour_12(h, !pm)); }));
163            }
164        }
165
166        // ── Dial ─────────────────────────────────────────────────────────────
167        let cx = r.origin.x + r.size.width / 2.0;
168        let cy = r.origin.y + HEADER_H + PAD + DIAL_D / 2.0;
169        let dial_r = DIAL_D / 2.0;
170        let num_r = dial_r - 22.0;        // outer ring the numbers sit on
171        let inner_r = num_r * 0.60;       // 24-hour inner ring (12–23)
172        let two_ring = self.use_24h && matches!(self.editing, TimeUnit::Hour);
173        let ring_mid = (num_r + inner_r) / 2.0;
174
175        // Dial hit + drag detection FIRST. `hoverable()` makes `pressed()` track
176        // the press, so a pressed dial = the user is dragging → the hand SNAPS to
177        // obey the finger; otherwise it animates.
178        let dial_rect = Rect { origin: Point { x: cx - dial_r, y: cy - dial_r }, size: Size { width: DIAL_D, height: DIAL_D } };
179        let dragging;
180        {
181            let dial = ctx.child(dial_rect);
182            dial.hoverable();
183            dragging = dial.pressed();
184            match &self.on_change {
185                Some(oc) => {
186                    let oc = oc.clone();
187                    let (unit, step, v, use_24h) = (self.editing, self.minute_step, self.value, self.use_24h);
188                    dial.on_press_at(move |px, py| {
189                        let dx = px - cx; let dy = py - cy;
190                        let mut deg = dx.atan2(-dy).to_degrees();
191                        if deg < 0.0 { deg += 360.0; }
192                        match unit {
193                            TimeUnit::Hour => {
194                                let h = (((deg / 30.0).round() as i32) % 12 + 12) % 12;
195                                if use_24h {
196                                    // Distance from centre picks the ring: inner = 12–23.
197                                    let inner = (dx * dx + dy * dy).sqrt() < ring_mid;
198                                    let hour = if inner { ((h + 12) % 24) as u8 } else { h as u8 };
199                                    oc(SimpleTime::new(hour, v.minute));
200                                } else {
201                                    let h12 = if h == 0 { 12 } else { h as u8 };
202                                    oc(v.with_hour_12(h12, v.hour_12().1));
203                                }
204                            }
205                            TimeUnit::Minute => {
206                                let m = (((deg / 6.0).round() as i32) % 60 + 60) % 60;
207                                let snapped = ((m as f32 / step as f32).round() as i32 * step as i32).rem_euclid(60) as u8;
208                                oc(SimpleTime::new(v.hour, snapped));
209                            }
210                        }
211                    });
212                }
213                None => dial.on_press_at(|_, _| {}),
214            }
215        }
216
217        // Auto-advance: releasing the dial while editing the Hour jumps to Minute.
218        // Channel 1 latches the previous frame's drag state so we can spot the
219        // press→release transition (there is no dedicated release event).
220        let was_dragging = ctx.anim_channel(1).unwrap_or(0.0) > 0.5;
221        if was_dragging && !dragging && matches!(self.editing, TimeUnit::Hour) {
222            if let Some(uc) = &self.on_unit_change {
223                uc(TimeUnit::Minute);
224            }
225        }
226        ctx.set_anim_channel(1, if dragging { 1.0 } else { 0.0 });
227
228        ctx.fill_circle(Point { x: cx, y: cy }, dial_r, dial_fill);
229
230        // Hand angle. Snap while dragging (obey the finger, no lag); otherwise
231        // ease along the SHORTEST path — unwrap the target to the equivalent
232        // nearest the current angle, so 11→12 goes clockwise, not the long way.
233        let target = self.target_angle();
234        ctx.seed_channel_if_unset(0, 0.0);
235        let angle = if dragging {
236            // Follow the finger CONTINUOUSLY (smooth), not the snapped value —
237            // the committed value still rounds to the nearest number (below), so
238            // releasing between two numbers picks the nearest.
239            let p = ctx.pointer();
240            let mut raw = (p.x - cx).atan2(-(p.y - cy)).to_degrees();
241            if raw < 0.0 { raw += 360.0; }
242            let cur = ctx.anim_channel(0).unwrap_or(raw);
243            while raw - cur > 180.0 { raw -= 360.0; }   // no 359→0 jump
244            while raw - cur < -180.0 { raw += 360.0; }
245            ctx.set_anim_channel(0, raw);
246            raw
247        } else {
248            let cur = ctx.anim_channel(0).unwrap_or(target);
249            // Shortest signed arc to `target` as a STABLE delta. Recomputing an
250            // unwrapped target with a while-loop each frame can flip sign when
251            // `cur` sits ~180° from `target`, trapping the hand in a frame-to-
252            // frame oscillation that never settles (perpetual repaint). The
253            // rem_euclid form always picks one consistent direction.
254            let d = (target - cur + 180.0).rem_euclid(360.0) - 180.0;
255            ctx.animate_channel(0, cur + d, 0.0)
256        };
257        // Hand length: inner ring for 24h hours 12–23 (or the ring the finger is
258        // near while dragging in 24h mode).
259        let hand_r = if two_ring {
260            if dragging {
261                let p = ctx.pointer();
262                if ((p.x - cx).powi(2) + (p.y - cy).powi(2)).sqrt() < ring_mid { inner_r } else { num_r }
263            } else if self.value.hour >= 12 { inner_r } else { num_r }
264        } else { num_r };
265        let (tx, ty) = on_circle(cx, cy, hand_r, angle);
266
267        // Hand: a SOLID line drawn as densely-overlapping circles (there is no
268        // line primitive). Step < radius so it reads as one continuous stroke,
269        // not dots. Stops short of the thumb so it doesn't overdraw the number.
270        let hand_len = ((tx - cx).powi(2) + (ty - cy).powi(2)).sqrt().max(1.0);
271        let stroke_r = 2.2;
272        let steps = (hand_len / (stroke_r * 0.7)).ceil() as i32;
273        let start = (10.0 / hand_len).clamp(0.0, 1.0); // leave the hub
274        let end = ((hand_len - 16.0) / hand_len).clamp(0.0, 1.0); // stop at thumb
275        for i in 0..=steps {
276            let t = start + (end - start) * (i as f32 / steps as f32);
277            ctx.fill_circle(Point { x: cx + (tx - cx) * t, y: cy + (ty - cy) * t }, stroke_r, hand_c);
278        }
279        ctx.fill_circle(Point { x: cx, y: cy }, 4.5, hand_c);          // centre hub
280        ctx.fill_circle(Point { x: tx, y: ty }, 18.0, thumb_c);        // thumb disc
281
282        // Numbers. A small helper to draw one centered number.
283        let num_at = |ctx: &mut PaintCtx, x: f32, y: f32, label: &str, sel: bool| {
284            let nw = ctx.font.measure_text(label, 15.0);
285            let nh = ctx.font.line_height(15.0);
286            ctx.draw_text_at(label, Point { x: x - nw / 2.0, y: y - nh / 2.0 }, if sel { sel_num_c } else { num_c }, 15.0);
287        };
288        if two_ring {
289            // 24-hour: outer ring 00–11, inner ring 12–23.
290            for i in 0..12 {
291                let deg = i as f32 * 30.0;
292                let (ox, oy) = on_circle(cx, cy, num_r, deg);
293                num_at(ctx, ox, oy, &format!("{:02}", i), self.value.hour == i as u8);
294                let (ix, iy) = on_circle(cx, cy, inner_r, deg);
295                let hr = (i + 12) as u8;
296                let sel = self.value.hour == hr;
297                let nw = ctx.font.measure_text(&format!("{hr:02}"), 15.0);
298                let nh = ctx.font.line_height(15.0);
299                // Inner numbers slightly smaller/dimmer unless selected.
300                ctx.draw_text_at(&format!("{hr:02}"), Point { x: ix - nw / 2.0, y: iy - nh / 2.0 },
301                    if sel { sel_num_c } else { with_alpha(num_c, 0.7) }, 15.0);
302            }
303        } else {
304            for i in 0..12 {
305                let deg = i as f32 * 30.0;
306                let (nx, ny) = on_circle(cx, cy, num_r, deg);
307                let label = match self.editing {
308                    TimeUnit::Hour => if i == 0 { "12".to_string() } else { i.to_string() },
309                    TimeUnit::Minute => format!("{:02}", i * 5),
310                };
311                let is_sel = match self.editing {
312                    TimeUnit::Hour => (self.value.hour_12().0 % 12) as i32 == i,
313                    TimeUnit::Minute => (self.value.minute as i32 / 5) == i && self.value.minute.is_multiple_of(5),
314                };
315                num_at(ctx, nx, ny, &label, is_sel);
316            }
317        }
318
319        ctx.semantics(super::Semantics::new(rosace_core::Role::Unknown)
320            .label(format!("Time picker, {h12:02}:{:02} {}", self.value.minute, ampm_label)));
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use rosace_layout::Constraints;
328
329    #[test]
330    #[ignore] // TIME_PNG=/path cargo test -p rosace-widgets clock_showcase -- --ignored --nocapture
331    fn clock_showcase() {
332        use super::super::app::WidgetApp;
333        let out = std::env::var("TIME_PNG").unwrap_or_else(|_| "clock.png".to_string());
334        let mut theme = rosace_theme::built_in::dark_theme();
335        theme.animation.enabled = false; // settled frame
336        let (w, h) = ((DIAL_D + PAD * 2.0) as u32, (HEADER_H + DIAL_D + PAD * 2.0) as u32);
337        std::fs::write(&out, WidgetApp::new(w, h).theme(theme.clone()).render_png(&TimePicker::new(SimpleTime::new(9, 30)))).unwrap();
338        // 24-hour dial (15:45 → the "15" sits on the inner ring).
339        std::fs::write(out.replace(".png", "_24h.png"),
340            WidgetApp::new(w, h).theme(theme).render_png(&TimePicker::new(SimpleTime::new(15, 45)).use_24h())).unwrap();
341        println!("wrote {out} + _24h");
342    }
343
344    #[test]
345    fn hour_12_conversion_round_trips() {
346        assert_eq!(SimpleTime::new(0, 0).hour_12(), (12, false));
347        assert_eq!(SimpleTime::new(12, 0).hour_12(), (12, true));
348        assert_eq!(SimpleTime::new(13, 30).hour_12(), (1, true));
349    }
350
351    #[test]
352    fn with_hour_12_reconstructs_24_hour() {
353        let base = SimpleTime::new(0, 45);
354        assert_eq!(base.with_hour_12(12, false).hour, 0);
355        assert_eq!(base.with_hour_12(1, true).hour, 13);
356    }
357
358    #[test]
359    fn target_angle_maps_hour_and_minute() {
360        // 3 o'clock → 90°, 9 o'clock → 270°, :30 → 180°.
361        assert_eq!(TimePicker::new(SimpleTime::new(3, 0)).target_angle(), 90.0);
362        assert_eq!(TimePicker::new(SimpleTime::new(9, 0)).target_angle(), 270.0);
363        assert_eq!(TimePicker::new(SimpleTime::new(0, 30)).editing(TimeUnit::Minute).target_angle(), 180.0);
364    }
365
366    #[test]
367    fn layout_is_dial_plus_header() {
368        let font = rosace_render::FontCache::embedded();
369        let theme = rosace_theme::built_in::dark_theme();
370        let ctx = LayoutCtx::new(Constraints::loose(400.0, 500.0), &font, &theme);
371        let size = TimePicker::new(SimpleTime::new(9, 30)).layout(&ctx);
372        assert_eq!(size.width, DIAL_D + PAD * 2.0);
373    }
374}