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