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