1use std::rc::Rc;
8
9use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
10use frus_layout::{Dimension, FlexDirection, Style};
11
12use crate::flex::Flex;
13use crate::grid::GridView;
14use crate::interaction::Status;
15use crate::text::Text;
16use crate::theme::Theme;
17use crate::widget::Widget;
18
19const CELL: f32 = 34.0;
20
21fn dial_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
29 over.or(theme.and_then(|t| t.widgets.time_picker.dial_text_style))
30 .unwrap_or_else(|| crate::theme::type_scale(theme).body_large)
31 .resolved()
32}
33
34fn preview_style(over: Option<TextStyle>, theme: Option<&Theme>) -> TextStyle {
41 over.or(theme.and_then(|t| t.widgets.time_picker.preview_text_style))
42 .unwrap_or_else(|| crate::theme::type_scale(theme).headline_medium)
43}
44
45fn help_style(over: Option<TextStyle>, theme: Option<&Theme>) -> TextStyle {
47 over.or(theme.and_then(|t| t.widgets.time_picker.help_text_style))
48 .unwrap_or_else(|| crate::theme::type_scale(theme).label_medium)
49}
50
51fn range_label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> TextStyle {
54 over.or(theme.and_then(|t| t.widgets.time_picker.range_label_text_style))
55 .unwrap_or_else(|| crate::theme::type_scale(theme).title_small)
56}
57
58struct TimeCell<Msg> {
60 label: String,
61 selected: bool,
62 day_period: bool,
65 text_style: Option<TextStyle>,
66 message: Option<Msg>,
67}
68
69impl<Msg> TimeCell<Msg> {
70 fn palette(&self, theme: &Theme, status: &Status) -> (Color, Color) {
83 match (self.selected, self.day_period) {
84 (true, true) => (
85 theme.scheme.tertiary_container,
86 theme.scheme.on_tertiary_container,
87 ),
88 (true, false) => (theme.primary, theme.on_primary),
89 (false, _) => (
90 theme.state_layer(theme.surface, theme.on_surface, status),
91 theme.on_surface,
92 ),
93 }
94 }
95}
96
97impl<Msg> TimeCell<Msg> {
98 fn sizing(&self, theme: Option<&Theme>) -> Style {
99 let side = frus_text::line_box(CELL, &dial_style(self.text_style, theme), 0.0);
100 Style {
101 width: Dimension::Length(side),
102 height: Dimension::Length(side),
103 ..Default::default()
104 }
105 }
106}
107
108impl<Msg: Clone> Widget<Msg> for TimeCell<Msg> {
109 fn style(&self) -> Style {
110 self.sizing(None)
111 }
112
113 fn style_themed(&self, theme: &Theme) -> Style {
114 self.sizing(Some(theme))
115 }
116
117 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
118 &[]
119 }
120
121 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
122 let o = status.opacity;
123 let (bg, fg) = self.palette(theme, &status);
124 scene.draw_rect(bounds, bg.fade(o), CELL * 0.5, 0.0, Color::TRANSPARENT);
125 let style = dial_style(self.text_style, Some(theme));
126 let w = frus_text::measure_resolved(&self.label, &style).width;
127 scene.text(
128 Point::new(
129 bounds.x + (bounds.width - w) * 0.5,
130 bounds.y + (bounds.height - style.line_height()) * 0.5,
131 ),
132 self.label.clone(),
133 &style,
134 fg.fade(o),
135 );
136 }
137
138 fn on_click(&self) -> Option<Msg> {
139 self.message.clone()
140 }
141
142 fn focusable(&self) -> bool {
143 self.message.is_some()
144 }
145}
146
147pub struct TimePicker<Msg = crate::callback::Callback> {
149 hour: u32,
150 minute: u32,
151 on_hour: Box<dyn Fn(u32) -> Msg>,
152 on_minute: Box<dyn Fn(u32) -> Msg>,
153 hour12: bool,
154 minute_step: u32,
155 dial_text_style: Option<TextStyle>,
156 preview_text_style: Option<TextStyle>,
157 help_text_style: Option<TextStyle>,
158 children: Vec<Box<dyn Widget<Msg>>>,
159}
160
161fn digit12(hour24: u32) -> u32 {
163 if hour24.is_multiple_of(12) {
164 12
165 } else {
166 hour24 % 12
167 }
168}
169
170impl<Msg: Clone + 'static> TimePicker<Msg> {
171 pub fn new(
175 hour: u32,
176 minute: u32,
177 on_hour: impl Fn(u32) -> Msg + 'static,
178 on_minute: impl Fn(u32) -> Msg + 'static,
179 ) -> Self {
180 let mut picker = Self {
181 hour: hour.min(23),
182 minute: minute.min(59),
183 on_hour: Box::new(on_hour),
184 on_minute: Box::new(on_minute),
185 hour12: false,
186 minute_step: 5,
187 dial_text_style: None,
188 preview_text_style: None,
189 help_text_style: None,
190 children: Vec::new(),
191 };
192 picker.rebuild();
193 picker
194 }
195
196 pub fn hour12(mut self) -> Self {
199 self.hour12 = true;
200 self.rebuild();
201 self
202 }
203
204 pub fn minute_step(mut self, step: u32) -> Self {
206 self.minute_step = step.clamp(1, 60);
207 self.rebuild();
208 self
209 }
210
211 #[must_use]
213 pub fn dial_text_style(mut self, style: TextStyle) -> Self {
214 self.dial_text_style = Some(style);
215 self.rebuild();
216 self
217 }
218
219 #[must_use]
221 pub fn preview_text_style(mut self, style: TextStyle) -> Self {
222 self.preview_text_style = Some(style);
223 self.rebuild();
224 self
225 }
226
227 #[must_use]
229 pub fn help_text_style(mut self, style: TextStyle) -> Self {
230 self.help_text_style = Some(style);
231 self.rebuild();
232 self
233 }
234
235 fn rebuild(&mut self) {
237 let (hour, minute) = (self.hour, self.minute);
238 let pm = hour >= 12;
239
240 let preview_s = preview_style(self.preview_text_style, None);
242 let help_s = help_style(self.help_text_style, None);
243 let cell = self.dial_text_style;
244 let preview = if self.hour12 {
245 let suffix = if pm { "PM" } else { "AM" };
246 Text::styled(format!("{}:{minute:02} {suffix}", digit12(hour)), preview_s)
247 } else {
248 Text::styled(format!("{hour:02}:{minute:02}"), preview_s)
249 };
250
251 let hours_section = if self.hour12 {
253 let am_target = if pm { hour - 12 } else { hour };
255 let pm_target = if pm { hour } else { hour + 12 };
256 let ampm = Flex::row()
257 .gap(4.0)
258 .child(TimeCell {
259 label: crate::localizations::of().ante_meridiem_label().into(),
260 selected: !pm,
261 day_period: true,
262 text_style: cell,
263 message: Some((self.on_hour)(am_target)),
264 })
265 .child(TimeCell {
266 label: crate::localizations::of().post_meridiem_label().into(),
267 selected: pm,
268 day_period: true,
269 text_style: cell,
270 message: Some((self.on_hour)(pm_target)),
271 });
272 let current12 = digit12(hour);
274 let mut grid = GridView::new(6).gap(4.0);
275 for d in 1..=12u32 {
276 let target24 = (d % 12) + if pm { 12 } else { 0 };
277 grid = grid.cell(TimeCell {
278 label: format!("{d}"),
279 selected: d == current12,
280 day_period: false,
281 text_style: cell,
282 message: Some((self.on_hour)(target24)),
283 });
284 }
285 Flex::column()
286 .gap(6.0)
287 .child(Text::styled(
288 crate::localizations::of().hour_label(),
289 help_s,
290 ))
291 .child(ampm)
292 .child(grid)
293 } else {
294 let mut grid = GridView::new(6).gap(4.0);
295 for h in 0..24u32 {
296 grid = grid.cell(TimeCell {
297 label: format!("{h:02}"),
298 selected: h == hour,
299 day_period: false,
300 text_style: cell,
301 message: Some((self.on_hour)(h)),
302 });
303 }
304 Flex::column()
305 .gap(6.0)
306 .child(Text::styled(
307 crate::localizations::of().hour_label(),
308 help_s,
309 ))
310 .child(grid)
311 };
312
313 let mut minutes = GridView::new(6).gap(4.0);
316 let mut m = 0;
317 while m < 60 {
318 minutes = minutes.cell(TimeCell {
319 label: format!("{m:02}"),
320 selected: m == minute,
321 day_period: false,
322 text_style: cell,
323 message: Some((self.on_minute)(m)),
324 });
325 m += self.minute_step;
326 }
327 let minutes_section = Flex::column()
328 .gap(6.0)
329 .child(Text::styled(
330 crate::localizations::of().minute_label(),
331 help_s,
332 ))
333 .child(minutes);
334
335 self.children = vec![
336 Box::new(preview),
337 Box::new(hours_section),
338 Box::new(minutes_section),
339 ];
340 }
341}
342
343impl<Msg> TimePicker<Msg> {
344 fn sizing(&self, theme: Option<&Theme>) -> Style {
347 let cell = frus_text::line_box(CELL, &dial_style(self.dial_text_style, theme), 0.0);
348 Style {
349 width: Dimension::Length(6.0 * (cell + 4.0)),
350 flex_direction: FlexDirection::Column,
351 gap: 12.0,
352 ..Default::default()
353 }
354 }
355}
356
357impl<Msg: Clone> Widget<Msg> for TimePicker<Msg> {
358 fn style(&self) -> Style {
359 self.sizing(None)
360 }
361
362 fn style_themed(&self, theme: &Theme) -> Style {
363 self.sizing(Some(theme))
364 }
365
366 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
367 &self.children
368 }
369
370 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
371
372 fn on_click(&self) -> Option<Msg> {
373 None
374 }
375}
376
377#[derive(Copy, Clone, Debug, PartialEq, Eq)]
379pub enum Endpoint {
380 Start,
382 End,
384}
385
386#[derive(Copy, Clone, Debug, PartialEq, Eq)]
388pub enum TimeField {
389 Hour,
391 Minute,
393}
394
395pub struct TimeRange<Msg = crate::callback::Callback> {
400 start: (u32, u32),
401 end: (u32, u32),
402 on_change: Rc<dyn Fn(Endpoint, TimeField, u32) -> Msg>,
403 label_text_style: Option<TextStyle>,
404 hour12: bool,
405 minute_step: u32,
406 children: Vec<Box<dyn Widget<Msg>>>,
407}
408
409impl<Msg: Clone + 'static> TimeRange<Msg> {
410 pub fn new(
413 start: (u32, u32),
414 end: (u32, u32),
415 on_change: impl Fn(Endpoint, TimeField, u32) -> Msg + 'static,
416 ) -> Self {
417 let mut range = Self {
418 label_text_style: None,
419 start: (start.0.min(23), start.1.min(59)),
420 end: (end.0.min(23), end.1.min(59)),
421 on_change: Rc::new(on_change),
422 hour12: false,
423 minute_step: 5,
424 children: Vec::new(),
425 };
426 range.rebuild();
427 range
428 }
429
430 #[must_use]
432 pub fn label_text_style(mut self, style: TextStyle) -> Self {
433 self.label_text_style = Some(style);
434 self.rebuild();
435 self
436 }
437
438 pub fn hour12(mut self) -> Self {
440 self.hour12 = true;
441 self.rebuild();
442 self
443 }
444
445 pub fn minute_step(mut self, step: u32) -> Self {
447 self.minute_step = step.clamp(1, 60);
448 self.rebuild();
449 self
450 }
451
452 fn rebuild(&mut self) {
454 let (hour12, step) = (self.hour12, self.minute_step);
455 let oc = self.on_change.clone();
456 let make = |ep: Endpoint, h: u32, m: u32| -> TimePicker<Msg> {
458 let (oc_h, oc_m) = (oc.clone(), oc.clone());
459 let mut tp = TimePicker::new(
460 h,
461 m,
462 move |hh| oc_h(ep, TimeField::Hour, hh),
463 move |mm| oc_m(ep, TimeField::Minute, mm),
464 );
465 if hour12 {
466 tp = tp.hour12();
467 }
468 tp.minute_step(step)
469 };
470 let label_s = range_label_style(self.label_text_style, None);
471 let start_col = Flex::column()
472 .gap(8.0)
473 .child(Text::styled(
474 crate::localizations::of().start_label(),
475 label_s,
476 ))
477 .child(make(Endpoint::Start, self.start.0, self.start.1));
478 let end_col = Flex::column()
479 .gap(8.0)
480 .child(Text::styled(
481 crate::localizations::of().end_label(),
482 label_s,
483 ))
484 .child(make(Endpoint::End, self.end.0, self.end.1));
485 self.children = vec![Box::new(start_col), Box::new(end_col)];
486 }
487}
488
489impl<Msg: Clone> Widget<Msg> for TimeRange<Msg> {
490 fn style(&self) -> Style {
491 Style {
492 flex_direction: FlexDirection::Row,
493 gap: 24.0,
494 ..Default::default()
495 }
496 }
497
498 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
499 &self.children
500 }
501
502 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
503
504 fn on_click(&self) -> Option<Msg> {
505 None
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use crate::{build_ui, Runtime, Size};
513 use frus_core::{Point, Primitive};
514
515 #[derive(Clone, Debug, PartialEq)]
516 enum Msg {
517 Hour(u32),
518 Minute(u32),
519 }
520
521 fn preview_text(ui: &crate::Ui<Msg>) -> Option<String> {
522 ui.scene().primitives().iter().find_map(|p| match p {
523 Primitive::Text { text, position, .. } if position.y < 40.0 && text.contains(':') => {
524 Some(text.clone())
525 }
526 _ => None,
527 })
528 }
529
530 fn cell_colors(selected: bool, day_period: bool, theme: &Theme) -> (Color, Color) {
532 let cell = TimeCell::<Msg> {
533 label: "AM".into(),
534 selected,
535 day_period,
536 text_style: None,
537 message: Some(Msg::Hour(0)),
538 };
539 let mut scene = Scene::new();
540 Widget::<Msg>::paint(
541 &cell,
542 Rect::new(0.0, 0.0, 40.0, 40.0),
543 Status {
544 opacity: 1.0,
545 ..Default::default()
546 },
547 theme,
548 &mut scene,
549 );
550 let bg = scene
551 .primitives()
552 .iter()
553 .find_map(|p| match p {
554 Primitive::Rect { color, .. } => Some(*color),
555 _ => None,
556 })
557 .expect("a cell paints a box");
558 let ink = scene
559 .primitives()
560 .iter()
561 .find_map(|p| match p {
562 Primitive::Text { color, .. } => Some(*color),
563 _ => None,
564 })
565 .expect("a cell paints its label");
566 (bg, ink)
567 }
568
569 #[test]
577 fn the_day_period_is_not_an_hour() {
578 let theme = Theme::default();
579 assert_eq!(
580 cell_colors(true, true, &theme),
581 (
582 theme.scheme.tertiary_container,
583 theme.scheme.on_tertiary_container
584 )
585 );
586 assert_eq!(
587 cell_colors(true, false, &theme),
588 (theme.primary, theme.on_primary)
589 );
590 assert_ne!(
591 cell_colors(true, true, &theme).0,
592 cell_colors(true, false, &theme).0,
593 "the two have to differ for the distinction to be worth making"
594 );
595 assert_eq!(
598 cell_colors(false, true, &theme),
599 cell_colors(false, false, &theme),
600 "only the selected one names a family"
601 );
602 }
603
604 #[test]
605 fn builds_header_hours_and_minutes() {
606 let tp = TimePicker::new(9, 30, Msg::Hour, Msg::Minute);
607 assert_eq!(Widget::<Msg>::children(&tp).len(), 3);
608 let hours_grid = &Widget::<Msg>::children(&tp)[1].children()[1];
610 assert_eq!(hours_grid.children().len(), 24);
611 let minutes_grid = &Widget::<Msg>::children(&tp)[2].children()[1];
613 assert_eq!(minutes_grid.children().len(), 12);
614 }
615
616 #[test]
617 fn minute_step_changes_the_minute_count() {
618 let tp = TimePicker::new(0, 0, Msg::Hour, Msg::Minute).minute_step(15);
619 let minutes_grid = &Widget::<Msg>::children(&tp)[2].children()[1];
620 assert_eq!(minutes_grid.children().len(), 4, "60/15 = 4 minutes");
621 }
622
623 #[test]
624 fn hour12_shows_am_pm_and_twelve_hours() {
625 let tp = TimePicker::new(15, 5, Msg::Hour, Msg::Minute).hour12();
627 let hours_section = &Widget::<Msg>::children(&tp)[1];
629 assert_eq!(hours_section.children().len(), 3);
630 let hours_grid = &hours_section.children()[2];
631 assert_eq!(hours_grid.children().len(), 12);
632
633 let ui = build_ui(
634 &tp,
635 Size::new(240.0, 360.0),
636 &Runtime::default(),
637 &Theme::default(),
638 );
639 assert_eq!(preview_text(&ui).as_deref(), Some("3:05 PM"));
640 }
641
642 #[test]
643 fn twentyfour_hour_preview() {
644 let tp = TimePicker::new(9, 30, Msg::Hour, Msg::Minute);
645 let ui = build_ui(
646 &tp,
647 Size::new(240.0, 320.0),
648 &Runtime::default(),
649 &Theme::default(),
650 );
651 assert_eq!(preview_text(&ui).as_deref(), Some("09:30"));
652 }
653
654 #[test]
655 fn clicking_a_cell_emits_a_message() {
656 let tp = TimePicker::new(0, 0, Msg::Hour, Msg::Minute);
657 let ui = build_ui(
658 &tp,
659 Size::new(240.0, 320.0),
660 &Runtime::default(),
661 &Theme::default(),
662 );
663 let click = |x: f32, y: f32| ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id));
664 let msg = (0..320)
665 .step_by(4)
666 .find_map(|y| click(CELL * 0.5, y as f32))
667 .expect("a clickable cell exists");
668 assert!(matches!(msg, Msg::Hour(_) | Msg::Minute(_)));
669 }
670
671 #[derive(Clone, Debug, PartialEq)]
672 enum RangeMsg {
673 Set(Endpoint, TimeField, u32),
674 }
675
676 #[test]
677 fn range_builds_start_and_end_pickers() {
678 let tr = TimeRange::new((9, 0), (17, 30), RangeMsg::Set).minute_step(15);
679 let cols = Widget::<RangeMsg>::children(&tr);
681 assert_eq!(cols.len(), 2);
682 let start_tp = &cols[0].children()[1];
683 assert_eq!(start_tp.children().len(), 3, "preview + hours + minutes");
684 let start_minutes = &start_tp.children()[2].children()[1];
686 assert_eq!(start_minutes.children().len(), 4);
687 let end_tp = &cols[1].children()[1];
689 let end_hours = &end_tp.children()[1].children()[1];
690 assert_eq!(
691 end_hours.children()[9].on_click(),
692 Some(RangeMsg::Set(Endpoint::End, TimeField::Hour, 9)),
693 );
694 }
695
696 #[test]
697 fn hour12_applies_to_both_pickers() {
698 let tr = TimeRange::new((15, 0), (20, 0), RangeMsg::Set).hour12();
699 let cols = Widget::<RangeMsg>::children(&tr);
700 for col in cols {
702 let tp = &col.children()[1];
703 assert_eq!(tp.children()[1].children().len(), 3);
704 }
705 }
706}