1use std::rc::Rc;
44
45use frust::authoring::{
46 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
47 LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, TypedArgCallback, View, Widget, any,
48 build_child, rebuild_child, route_event, route_event_single, teardown_child, visit_children,
49};
50use kurbo::{Point, Size};
51
52use super::{TimeEntry, TimePickerStrings};
53use crate::button::{ButtonVariant, button};
54use crate::text_field::text_field;
55
56const FIELD_GAP: f64 = 16.0;
60const PERIOD_GAP: f64 = 8.0;
63
64pub struct TimeInputView<State: 'static> {
66 entry: TimeEntry,
67 use_24_hour: bool,
68 autovalidate: bool,
69 strings: Rc<TimePickerStrings>,
70 on_change: TypedArgCallback<State, TimeEntry>,
71}
72
73pub fn time_input<State: 'static, F: Fn(&mut State, TimeEntry) + 'static>(
76 entry: TimeEntry,
77 on_change: F,
78) -> TimeInputView<State> {
79 TimeInputView {
80 entry,
81 use_24_hour: false,
82 autovalidate: false,
83 strings: Rc::new(TimePickerStrings::default()),
84 on_change: Rc::new(on_change),
85 }
86}
87
88impl<State: 'static> TimeInputView<State> {
89 pub fn use_24_hour(mut self, use_24_hour: bool) -> Self {
92 self.use_24_hour = use_24_hour;
93 self
94 }
95
96 pub fn autovalidate(mut self, autovalidate: bool) -> Self {
99 self.autovalidate = autovalidate;
100 self
101 }
102
103 pub fn strings(mut self, strings: TimePickerStrings) -> Self {
105 self.strings = Rc::new(strings);
106 self
107 }
108
109 pub(super) fn shared_strings(mut self, strings: Rc<TimePickerStrings>) -> Self {
112 self.strings = strings;
113 self
114 }
115
116 fn error_for(&self, valid: bool) -> Option<&str> {
119 (self.autovalidate && !valid).then_some(self.strings.invalid_time.as_str())
120 }
121
122 fn hour_field(&self) -> impl View<State> + use<State> {
123 let on_change = self.on_change.clone();
124 let entry = self.entry.clone();
125 let mut field = text_field(
126 self.entry.hour_text.clone(),
127 move |state: &mut State, text: String| {
128 on_change(state, entry.clone().with_hour_text(text));
129 },
130 )
131 .label(self.strings.hour_label.clone());
132 if let Some(error) = self.error_for(self.entry.hour_is_valid(self.use_24_hour)) {
133 field = field.error_text(error);
134 }
135 field
136 }
137
138 fn minute_field(&self) -> impl View<State> + use<State> {
139 let on_change = self.on_change.clone();
140 let entry = self.entry.clone();
141 let mut field = text_field(
142 self.entry.minute_text.clone(),
143 move |state: &mut State, text: String| {
144 on_change(state, entry.clone().with_minute_text(text));
145 },
146 )
147 .label(self.strings.minute_label.clone());
148 if let Some(error) = self.error_for(self.entry.minute_is_valid()) {
149 field = field.error_text(error);
150 }
151 field
152 }
153
154 fn period_button(&self, pm: bool) -> AnyView<State> {
158 let on_change = self.on_change.clone();
159 let entry = self.entry.clone();
160 let label = if pm {
161 self.strings.pm.clone()
162 } else {
163 self.strings.am.clone()
164 };
165 let selected = self.entry.pm == pm;
166 any(button(label, move |state: &mut State| {
167 on_change(state, entry.clone().with_pm(pm));
168 })
169 .variant(if selected {
170 ButtonVariant::Filled
171 } else {
172 ButtonVariant::Outlined
173 }))
174 }
175
176 fn period_buttons(&self) -> Vec<AnyView<State>> {
178 if self.use_24_hour {
179 Vec::new()
180 } else {
181 vec![self.period_button(false), self.period_button(true)]
182 }
183 }
184}
185
186pub struct TimeInputWidget {
188 hour: ChildPod,
189 minute: ChildPod,
190 period: Vec<ChildPod>,
192}
193
194impl<State: 'static> View<State> for TimeInputView<State> {
195 type Element = TimeInputWidget;
196
197 fn build(&self, ctx: &mut BuildCtx<'_>) -> TimeInputWidget {
198 TimeInputWidget {
199 hour: build_child(&self.hour_field(), ctx),
200 minute: build_child(&self.minute_field(), ctx),
201 period: self
202 .period_buttons()
203 .iter()
204 .map(|v| build_child(v, ctx))
205 .collect(),
206 }
207 }
208
209 fn rebuild(
210 &self,
211 prev: &Self,
212 element: &mut TimeInputWidget,
213 ctx: &mut BuildCtx<'_>,
214 ) -> ChangeFlags {
215 let mut flags = rebuild_child(
216 &prev.hour_field(),
217 &self.hour_field(),
218 &mut element.hour,
219 ctx,
220 );
221 flags |= rebuild_child(
222 &prev.minute_field(),
223 &self.minute_field(),
224 &mut element.minute,
225 ctx,
226 );
227
228 let prev_period = prev.period_buttons();
229 let next_period = self.period_buttons();
230 if prev_period.len() == next_period.len() {
231 for ((p, n), pod) in prev_period
232 .iter()
233 .zip(next_period.iter())
234 .zip(element.period.iter_mut())
235 {
236 flags |= rebuild_child(p, n, pod, ctx);
237 }
238 } else {
239 for (view, pod) in prev_period.iter().zip(element.period.iter_mut()) {
241 teardown_child(view, pod, ctx);
242 }
243 element.period = next_period.iter().map(|v| build_child(v, ctx)).collect();
244 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
245 }
246 flags
247 }
248
249 fn teardown(&self, element: &mut TimeInputWidget, ctx: &mut BuildCtx<'_>) {
250 teardown_child(&self.hour_field(), &mut element.hour, ctx);
251 teardown_child(&self.minute_field(), &mut element.minute, ctx);
252 for (view, pod) in self.period_buttons().iter().zip(element.period.iter_mut()) {
253 teardown_child(view, pod, ctx);
254 }
255 }
256}
257
258impl Widget for TimeInputWidget {
259 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
260 let width = bc.max().width.max(bc.min().width);
261 let field_w = ((width - FIELD_GAP) / 2.0).max(0.0);
264 let field_bc =
265 BoxConstraints::new(Size::new(field_w, 0.0), Size::new(field_w, f64::INFINITY));
266 let hour = self.hour.layout_child(ctx, &field_bc);
267 let minute = self.minute.layout_child(ctx, &field_bc);
268 self.hour.set_origin(Point::ZERO);
269 self.minute.set_origin(Point::new(field_w + FIELD_GAP, 0.0));
270
271 let mut y = hour.height.max(minute.height);
272
273 if !self.period.is_empty() {
274 y += FIELD_GAP;
275 let loose = BoxConstraints::loose(Size::new(width, f64::INFINITY));
276 let mut x = 0.0;
277 let mut row_h: f64 = 0.0;
278 for (i, pod) in self.period.iter_mut().enumerate() {
279 let s = pod.layout_child(ctx, &loose);
280 if i > 0 {
281 x += PERIOD_GAP;
282 }
283 pod.set_origin(Point::new(x, y));
284 x += s.width;
285 row_h = row_h.max(s.height);
286 }
287 y += row_h;
288 }
289
290 bc.constrain(Size::new(width, y))
291 }
292
293 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
294 self.hour.paint_child(ctx, scene);
295 self.minute.paint_child(ctx, scene);
296 for pod in &mut self.period {
297 pod.paint_child(ctx, scene);
298 }
299 }
300
301 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
302 if route_event_single(&mut self.hour, ctx, event) == EventResult::Handled {
303 return EventResult::Handled;
304 }
305 if route_event_single(&mut self.minute, ctx, event) == EventResult::Handled {
306 return EventResult::Handled;
307 }
308 route_event(&mut self.period, ctx, event)
309 }
310
311 fn semantics(&self, ctx: &mut SemanticsCtx) {
312 self.hour.semantics_child(ctx);
313 self.minute.semantics_child(ctx);
314 for pod in &self.period {
315 pod.semantics_child(ctx);
316 }
317 }
318
319 visit_children!(hour, minute, period);
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::time_picker::TimeOfDay;
326 use frust::authoring::text::TextContext;
327 use std::any::Any;
328
329 #[test]
330 fn hour_validity_follows_the_format() {
331 for (text, ok_24, ok_12) in [
332 ("0", true, false),
333 ("00", true, false),
334 ("1", true, true),
335 ("12", true, true),
336 ("13", true, false),
337 ("23", true, false),
338 ("24", false, false),
339 ("", false, false),
340 ("ab", false, false),
341 ("1 2", false, false),
342 ("-1", false, false),
343 ("999999999999999999999", false, false),
344 ] {
345 let entry = TimeEntry::default().with_hour_text(text);
346 assert_eq!(entry.hour_is_valid(true), ok_24, "24-hour `{text}`");
347 assert_eq!(entry.hour_is_valid(false), ok_12, "12-hour `{text}`");
348 }
349 }
350
351 #[test]
352 fn minute_validity_is_zero_through_fifty_nine() {
353 for (text, ok) in [
354 ("0", true),
355 ("00", true),
356 ("59", true),
357 ("60", false),
358 ("", false),
359 ("x", false),
360 ] {
361 assert_eq!(
362 TimeEntry::default()
363 .with_minute_text(text)
364 .minute_is_valid(),
365 ok,
366 "`{text}`"
367 );
368 }
369 }
370
371 #[test]
372 fn parse_folds_the_twelve_hour_half_through_the_period_flag() {
373 let am = TimeEntry {
374 hour_text: "12".into(),
375 minute_text: "05".into(),
376 pm: false,
377 };
378 assert_eq!(
379 am.parse(false),
380 Some(TimeOfDay::new(0, 5)),
381 "12 AM is 00:05"
382 );
383 let pm = am.clone().with_pm(true);
384 assert_eq!(
385 pm.parse(false),
386 Some(TimeOfDay::new(12, 5)),
387 "12 PM is 12:05"
388 );
389 let nine_pm = TimeEntry {
390 hour_text: "9".into(),
391 minute_text: "30".into(),
392 pm: true,
393 };
394 assert_eq!(nine_pm.parse(false), Some(TimeOfDay::new(21, 30)));
395 assert_eq!(nine_pm.parse(true), Some(TimeOfDay::new(9, 30)));
397 }
398
399 #[test]
400 fn parse_refuses_an_incomplete_or_out_of_range_entry() {
401 for entry in [
402 TimeEntry::default(),
403 TimeEntry::default().with_hour_text("10"),
404 TimeEntry::default().with_minute_text("10"),
405 TimeEntry {
406 hour_text: "25".into(),
407 minute_text: "00".into(),
408 pm: false,
409 },
410 TimeEntry {
411 hour_text: "10".into(),
412 minute_text: "61".into(),
413 pm: false,
414 },
415 ] {
416 assert_eq!(entry.parse(true), None, "{entry:?}");
417 }
418 }
419
420 #[test]
421 fn surrounding_whitespace_is_trimmed_before_validating() {
422 let entry = TimeEntry {
423 hour_text: " 09 ".into(),
424 minute_text: "\t15\n".into(),
425 pm: false,
426 };
427 assert!(entry.hour_is_valid(true));
428 assert!(entry.minute_is_valid());
429 assert_eq!(entry.parse(true), Some(TimeOfDay::new(9, 15)));
430 }
431
432 #[test]
433 fn from_time_seeds_zero_padded_text_and_the_period_flag() {
434 let entry = TimeEntry::from_time(TimeOfDay::new(21, 5), false);
435 assert_eq!(entry.hour_text, "09");
436 assert_eq!(entry.minute_text, "05");
437 assert!(entry.pm);
438 let entry24 = TimeEntry::from_time(TimeOfDay::new(21, 5), true);
439 assert_eq!(entry24.hour_text, "21");
440 assert_eq!(entry24.minute_text, "05");
441 }
442
443 #[derive(Default)]
448 struct App {
449 entry: TimeEntry,
450 }
451
452 fn view(entry: TimeEntry, use_24_hour: bool) -> TimeInputView<App> {
453 time_input(entry, |s: &mut App, e: TimeEntry| s.entry = e).use_24_hour(use_24_hour)
454 }
455
456 fn mount(view: &TimeInputView<App>) -> (TimeInputWidget, TextContext) {
459 let mut counter = 0u64;
460 let mut widget = View::<App>::build(view, &mut BuildCtx::new(&mut counter));
461 let mut tcx = TextContext::new();
462 {
463 let mut layout_ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
464 widget.layout(
465 &mut layout_ctx,
466 &BoxConstraints::tight(Size::new(320.0, 400.0)),
467 );
468 }
469 (widget, tcx)
470 }
471
472 #[test]
473 fn twelve_hour_mode_shows_the_period_row_and_twenty_four_hour_mode_does_not() {
474 let entry = TimeEntry::from_time(TimeOfDay::new(9, 15), false);
475 let twelve = view(entry.clone(), false);
476 let (mut widget, _tcx) = mount(&twelve);
477 assert_eq!(widget.period.len(), 2);
478
479 let mut counter = 0u64;
480 let twenty_four = view(entry.clone(), true);
481 View::<App>::rebuild(
482 &twenty_four,
483 &twelve,
484 &mut widget,
485 &mut BuildCtx::new(&mut counter),
486 );
487 assert_eq!(widget.period.len(), 0, "the row is torn down");
488
489 let back = view(entry, false);
490 View::<App>::rebuild(
491 &back,
492 &twenty_four,
493 &mut widget,
494 &mut BuildCtx::new(&mut counter),
495 );
496 assert_eq!(widget.period.len(), 2, "and rebuilt on the way back");
497 }
498
499 #[test]
500 fn autovalidate_gates_the_error_line() {
501 let entry = TimeEntry {
502 hour_text: "99".into(),
503 minute_text: "".into(),
504 pm: false,
505 };
506 let quiet: TimeInputView<App> = time_input(entry.clone(), |_: &mut App, _| {});
507 assert_eq!(quiet.error_for(entry.hour_is_valid(false)), None);
508 let loud: TimeInputView<App> =
509 time_input(entry.clone(), |_: &mut App, _| {}).autovalidate(true);
510 assert_eq!(
511 loud.error_for(entry.hour_is_valid(false)),
512 Some(TimePickerStrings::default().invalid_time.as_str())
513 );
514 assert_eq!(
515 loud.error_for(true),
516 None,
517 "a well-formed field stays quiet even under autovalidation"
518 );
519 }
520
521 #[test]
522 fn the_form_lays_both_fields_out_side_by_side() {
523 let entry = TimeEntry::from_time(TimeOfDay::new(9, 15), false);
524 let (widget, _tcx) = mount(&view(entry, false));
525 assert_eq!(widget.hour.origin(), Point::ZERO);
526 assert_eq!(widget.hour.origin().y, widget.minute.origin().y, "same row");
527 assert!(
528 (widget.minute.origin().x - (widget.hour.size().width + FIELD_GAP)).abs() < 1e-9,
529 "one 16dp gap between the fields"
530 );
531 assert!(
532 widget.period[0].origin().y >= widget.hour.size().height + FIELD_GAP,
533 "the period row sits one gap below the fields"
534 );
535 }
536}