1use 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#[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 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#[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
45pub 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 pub fn editing(mut self, u: TimeUnit) -> Self { self.editing = u; self }
72 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 pub fn dial_color(mut self, c: Color) -> Self { self.dial_color = Some(c); self }
79 pub fn hand_color(mut self, c: Color) -> Self { self.hand_color = Some(c); self }
81 pub fn thumb_color(mut self, c: Color) -> Self { self.thumb_color = Some(c); self }
83 pub fn number_color(mut self, c: Color) -> Self { self.number_color = Some(c); self }
85 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}
105fn 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 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 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 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; let inner_r = num_r * 0.60; let two_ring = self.use_24h && matches!(self.editing, TimeUnit::Hour);
173 let ring_mid = (num_r + inner_r) / 2.0;
174
175 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 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 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 let target = self.target_angle();
234 ctx.seed_channel_if_unset(0, 0.0);
235 let angle = if dragging {
236 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; } 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 let d = (target - cur + 180.0).rem_euclid(360.0) - 180.0;
255 ctx.animate_channel(0, cur + d, 0.0)
256 };
257 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 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); let end = ((hand_len - 16.0) / hand_len).clamp(0.0, 1.0); 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); ctx.fill_circle(Point { x: tx, y: ty }, 18.0, thumb_c); 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 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 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] 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; 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 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 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}