1use super::edit_menu::{self, EditAction, TextMenu};
4use crate::date::TimeOfDay;
5use crate::event::{Event, KeyEvent, MouseButton, MouseKind};
6use crate::geometry::{Rect, Size};
7use crate::keymap::{Key, Modifiers, Scope};
8use crate::style::CellStyle;
9use crate::text;
10use crate::theme::State;
11use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
12
13const SEGMENT: u16 = 4;
15
16const MAX: [u8; 3] = [TimeOfDay::LARGEST.hour, TimeOfDay::LARGEST.minute, TimeOfDay::LARGEST.second];
18
19fn part(time: TimeOfDay, index: usize) -> u8 {
21 [time.hour, time.minute, time.second][index]
22}
23
24fn with_part(time: TimeOfDay, index: usize, value: u8) -> TimeOfDay {
26 let mut parts = [time.hour, time.minute, time.second];
27 parts[index] = value.min(MAX[index]);
28 TimeOfDay { hour: parts[0], minute: parts[1], second: parts[2] }
29}
30
31fn write(time: TimeOfDay, seconds: bool) -> String {
33 if seconds { time.to_string() } else { format!("{:02}:{:02}", time.hour, time.minute) }
34}
35
36type TimeMessage<Msg> = Box<dyn Fn(TimeOfDay) -> Msg>;
38
39pub struct TimeInput<Msg> {
61 value: TimeOfDay,
62 seconds: bool,
63 invalid: bool,
64 disabled: bool,
65 on_change: Option<TimeMessage<Msg>>,
66}
67
68#[derive(Debug, Default)]
69struct TimeMemory {
70 segment: usize,
72 pending: Option<u8>,
74 all: bool,
76 hovered: Option<usize>,
79}
80
81impl<Msg> TimeInput<Msg> {
82 #[must_use]
84 pub fn new(value: TimeOfDay) -> Self {
85 Self { value, seconds: false, invalid: false, disabled: false, on_change: None }
86 }
87
88 #[must_use]
90 pub fn seconds(mut self, seconds: bool) -> Self {
91 self.seconds = seconds;
92 self
93 }
94
95 #[must_use]
97 pub fn invalid(mut self, invalid: bool) -> Self {
98 self.invalid = invalid;
99 self
100 }
101
102 #[must_use]
104 pub fn disabled(mut self, disabled: bool) -> Self {
105 self.disabled = disabled;
106 self
107 }
108
109 #[must_use]
111 pub fn on_change(mut self, message: impl Fn(TimeOfDay) -> Msg + 'static) -> Self {
112 self.on_change = Some(Box::new(message));
113 self
114 }
115
116 fn active(&self) -> bool {
117 !self.disabled && self.on_change.is_some()
118 }
119
120 fn count(&self) -> usize {
121 if self.seconds { 3 } else { 2 }
122 }
123
124 fn offset(index: usize) -> u16 {
126 u16::try_from(index).unwrap_or(0) * (SEGMENT + 1)
128 }
129
130 fn width(&self) -> u16 {
131 Self::offset(self.count() - 1) + SEGMENT
132 }
133
134 fn change(&self, cx: &mut EventCx<'_, Msg>, value: TimeOfDay) {
135 if value != self.value
136 && let Some(message) = &self.on_change
137 {
138 cx.emit(message(value));
139 }
140 }
141
142 fn type_digit(&self, cx: &mut EventCx<'_, Msg>, digit: u8) {
144 let last = self.count() - 1;
145 let memory = cx.memory::<TimeMemory>();
146 let segment = memory.segment.min(last);
147 let max = MAX[segment];
148 let joined = memory.pending.map(|first| first * 10 + digit).filter(|joined| *joined <= max);
149 let (value, complete) = match joined {
150 Some(joined) => (joined, true),
151 None => (digit, digit * 10 > max),
153 };
154 memory.pending = if complete { None } else { Some(digit) };
155 if complete && segment < last {
156 memory.segment = segment + 1;
157 }
158 self.change(cx, with_part(self.value, segment, value));
159 }
160
161 fn step(&self, cx: &mut EventCx<'_, Msg>, segment: usize, up: bool) {
163 let last = self.count() - 1;
164 let memory = cx.memory::<TimeMemory>();
165 if segment == memory.segment.min(last) {
167 memory.pending = None;
168 }
169 let span = u16::from(MAX[segment]) + 1;
170 let delta = if up { 1 } else { span - 1 };
171 let value = (u16::from(part(self.value, segment)) + delta) % span;
172 self.change(cx, with_part(self.value, segment, u8::try_from(value).unwrap_or(0)));
173 }
174
175 fn segment_at(&self, offset: i32) -> Option<usize> {
178 (0..self.count()).find(|index| {
179 let start = i32::from(Self::offset(*index));
180 (start..start + i32::from(SEGMENT)).contains(&offset)
181 })
182 }
183
184 fn wheel_target(&self, cx: &mut EventCx<'_, Msg>, x: i32) -> usize {
187 let last = self.count() - 1;
188 let under = self.segment_at(x - cx.area().x);
189 let memory = cx.memory::<TimeMemory>();
190 if under.is_some() {
191 memory.hovered = under;
192 }
193 under.or(memory.hovered).unwrap_or(memory.segment).min(last)
194 }
195
196 fn activate_at(&self, cx: &mut EventCx<'_, Msg>, x: i32) {
198 let offset = x - cx.area().x;
199 let index = (0..self.count()).rev().find(|index| offset >= i32::from(Self::offset(*index))).unwrap_or(0);
200 let memory = cx.memory::<TimeMemory>();
201 memory.segment = index;
202 memory.pending = None;
203 }
204
205 fn apply(&self, cx: &mut EventCx<'_, Msg>, action: EditAction) {
207 let all = std::mem::take(&mut cx.memory::<TimeMemory>().all);
208 match action {
209 EditAction::Cut | EditAction::Copy if all => {
210 cx.copy(write(self.value, self.seconds));
211 if action == EditAction::Cut {
212 self.change(cx, TimeOfDay::default());
213 }
214 }
215 EditAction::Cut | EditAction::Copy => {}
216 EditAction::Paste => cx.run_action(Scope::Global, "paste"),
217 EditAction::SelectAll => cx.memory::<TimeMemory>().all = true,
218 }
219 }
220
221 fn chord_action(key: &KeyEvent) -> Option<EditAction> {
223 let ctrl = Modifiers { ctrl: true, ..Modifiers::default() };
224 match key.chord.key {
225 Key::Char('a') if key.chord.mods == ctrl => Some(EditAction::SelectAll),
226 Key::Char('c') if key.chord.mods == ctrl => Some(EditAction::Copy),
227 Key::Char('x') if key.chord.mods == ctrl => Some(EditAction::Cut),
228 _ => None,
229 }
230 }
231
232 fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
235 let open = edit_menu::is_open(cx);
236 if !open && !edit_menu::asks(event) {
237 return false;
238 }
239 if let Event::Mouse(mouse) = event
240 && !open
241 && !cx.memory::<TimeMemory>().all
242 {
243 self.activate_at(cx, mouse.x);
244 }
245 let all = cx.memory::<TimeMemory>().all;
246 let (used, chosen) = TextMenu::edit(cx.env(), all, cx.can_paste()).event(cx, event);
247 if used && !open {
248 cx.probe_clipboard();
249 }
250 if let Some(action) = chosen {
251 self.apply(cx, action);
252 }
253 used
254 }
255}
256
257impl<Msg: 'static> Widget<Msg> for TimeInput<Msg> {
258 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
259 Size::new(self.width(), 1).min(available)
260 }
261
262 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
263 let mut states = if self.active() { cx.states() } else { Vec::new() };
264 if self.invalid {
265 states.push(State::Invalid);
266 }
267 if self.disabled {
268 states.push(State::Disabled);
269 }
270 let focused = states.contains(&State::Focus);
271 let pointer = cx.pointer().map(|(x, _)| self.segment_at(x - area.x));
274 let (segment, pending, all) = {
275 let memory = cx.memory::<TimeMemory>();
276 if !focused {
277 *memory = TimeMemory { hovered: memory.hovered, ..TimeMemory::default() };
278 }
279 match pointer {
280 None => memory.hovered = None,
281 Some(Some(index)) => memory.hovered = Some(index),
282 Some(None) => {}
283 }
284 (memory.segment, memory.pending, memory.all)
285 };
286 let selection = cx.style("text-input-selection", None, &states).text();
287 let field_style = cx.style("time-input", None, &states);
288 let surface = field_style.text();
289 let field = Rect::new(area.x, area.y, self.width().min(area.width), 1);
290 cx.clear(field, surface.bg.unwrap_or_else(|| cx.color("raised")));
291 let pillar = field_style.color("pillar");
292 if self.active() {
293 cx.register_hit(field);
294 edit_menu::request_overlay(cx, field);
295 }
296 let separator = cx.style("time-separator", None, &states).text();
297 if let Some(bg) = selection.bg.filter(|_| all) {
300 cx.fill(field, bg);
301 }
302 for index in 0..self.count() {
303 let x = field.x + i32::from(Self::offset(index));
304 if index > 0 {
305 cx.text(x - 1, field.y, ":", CellStyle { bg: None, ..separator }, 1);
306 }
307 let mut segment_states = states.clone();
308 if focused && index == segment && !all {
309 segment_states.push(State::Selected);
310 if pending.is_some() {
311 segment_states.push(State::Active);
312 }
313 }
314 let mut style = cx.style("time-segment", None, &segment_states).text();
315 if all {
316 style.bg = None;
317 style.fg = selection.fg.or(style.fg);
318 }
319 let rect = Rect::new(x, field.y, SEGMENT, 1).intersect(field);
320 if let Some(bg) = style.bg {
321 cx.clear(rect, bg);
322 }
323 let digits = format!("{:02}", part(self.value, index));
324 let text_style = CellStyle { bg: None, fg: style.fg.or(surface.fg), ..style };
325 cx.text(x + 1, field.y, &digits, text_style, text::width(&digits).min(rect.width.saturating_sub(1)));
326 }
327 if let Some(color) = pillar {
329 cx.pillar(field.x, field.y, color);
330 }
331 }
332
333 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
334 let all = cx.memory::<TimeMemory>().all;
335 TextMenu::edit(cx.env(), all, cx.can_paste()).paint_overlay(cx, anchor);
336 }
337
338 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
339 if !self.active() {
340 return false;
341 }
342 if self.menu_event(cx, event) {
343 return true;
344 }
345 let last = self.count() - 1;
346 match event {
347 Event::Key(key) if let Some(action) = Self::chord_action(key) => {
348 self.apply(cx, action);
349 true
350 }
351 Event::Paste(text) => {
352 cx.memory::<TimeMemory>().all = false;
353 let Some(time) = TimeOfDay::parse(text) else {
354 return false;
355 };
356 let time = if self.seconds { time } else { TimeOfDay { second: self.value.second, ..time } };
357 self.change(cx, time);
358 true
359 }
360 Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp | MouseKind::ScrollDown) => {
361 let up = mouse.kind == MouseKind::ScrollUp;
362 let segment = self.wheel_target(cx, mouse.x);
363 self.step(cx, segment, up);
364 true
365 }
366 Event::Key(key) => {
367 cx.memory::<TimeMemory>().all = false;
368 let segment = cx.memory::<TimeMemory>().segment.min(last);
369 if key.is_plain(Key::Left) || key.is_plain(Key::Right) {
370 let memory = cx.memory::<TimeMemory>();
371 memory.segment =
372 if key.is_plain(Key::Left) { segment.saturating_sub(1) } else { (segment + 1).min(last) };
373 memory.pending = None;
374 return true;
375 }
376 if key.is_plain(Key::Up) || key.is_plain(Key::Down) {
377 self.step(cx, segment, key.is_plain(Key::Up));
378 return true;
379 }
380 if key.is_plain(Key::Backspace) {
381 cx.memory::<TimeMemory>().pending = None;
382 self.change(cx, with_part(self.value, segment, 0));
383 return true;
384 }
385 match key.text {
386 Some(':') => {
387 let memory = cx.memory::<TimeMemory>();
388 memory.segment = (segment + 1).min(last);
389 memory.pending = None;
390 true
391 }
392 Some(c) if c.is_ascii_digit() => {
393 self.type_digit(cx, u8::try_from(c.to_digit(10).unwrap_or(0)).unwrap_or(0));
394 true
395 }
396 _ => false,
397 }
398 }
399 Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Left) => {
400 cx.memory::<TimeMemory>().all = false;
401 self.activate_at(cx, mouse.x);
402 true
403 }
404 _ => false,
405 }
406 }
407
408 fn focusable(&self) -> bool {
409 self.active()
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::runtime::{App, Command, Harness};
417 use crate::widget::View;
418
419 struct Demo {
420 time: TimeOfDay,
421 seconds: bool,
422 disabled: bool,
423 }
424
425 impl App for Demo {
426 type Msg = TimeOfDay;
427 fn update(&mut self, time: TimeOfDay) -> Command<TimeOfDay> {
428 self.time = time;
429 Command::none()
430 }
431 fn view(&self, ui: &mut View<'_, TimeOfDay>) {
432 ui.add(TimeInput::new(self.time).seconds(self.seconds).disabled(self.disabled).on_change(|t| t))
433 .id("starts");
434 }
435 }
436
437 fn harness(hour: u8, minute: u8, seconds: bool) -> Harness<Demo> {
438 Harness::new(Demo { time: TimeOfDay::new(hour, minute, 0), seconds, disabled: false }, 20, 1)
439 }
440
441 #[test]
442 fn draws_segments_with_a_faint_colon_and_raises_the_active_one() {
443 let mut h = harness(9, 5, false);
444 assert_eq!(h.screen(), " 09 : 05\n");
445 let theme = h.env().theme();
446 assert_eq!(h.fg(4, 0), theme.color("muted"));
447 let idle = h.bg(1, 0);
448 h.press("tab");
449 assert_ne!(h.bg(1, 0), h.bg(6, 0), "the active segment stands out");
450 assert_ne!(h.bg(1, 0), idle);
451 h.press("right");
452 assert_eq!(h.bg(1, 0), h.bg(4, 0), "the first segment is back on the field surface");
453 let mut h = harness(23, 59, true);
454 assert_eq!(h.screen(), " 23 : 59 : 00\n");
455 h.set_glyph_mode(crate::icons::GlyphMode::Ascii);
456 assert!(h.screen().is_ascii());
457 }
458
459 #[test]
460 fn arrows_move_between_segments_and_wrap_values() {
461 let mut h = harness(23, 0, false);
462 h.press("tab").press("up");
463 assert_eq!(h.app().time, TimeOfDay::new(0, 0, 0));
464 h.press("down");
465 assert_eq!(h.app().time, TimeOfDay::new(23, 0, 0));
466 h.press("right").press("down");
467 assert_eq!(h.app().time, TimeOfDay::new(23, 59, 0));
468 h.press("right").press("up");
469 assert_eq!(h.app().time, TimeOfDay::new(23, 0, 0), "right stops at the last segment");
470 }
471
472 #[test]
473 fn typing_fills_segments_and_moves_on() {
474 let mut h = harness(0, 0, true);
475 h.press("tab").type_text("0930");
476 assert_eq!(h.app().time, TimeOfDay::new(9, 30, 0));
477 h.press("left").press("left").type_text("7");
478 assert_eq!(h.app().time, TimeOfDay::new(7, 30, 0), "7 cannot start an hour, so it completes it");
479 h.type_text("45").type_text("2");
480 assert_eq!(h.app().time, TimeOfDay::new(7, 45, 2));
481 h.type_text("9");
482 assert_eq!(h.app().time, TimeOfDay::new(7, 45, 29));
483 h.press("left").press("left").type_text("24");
484 assert_eq!(h.app().time, TimeOfDay::new(4, 45, 29), "24 is no hour; the 4 stands alone");
485 h.press("backspace");
486 assert_eq!(h.app().time, TimeOfDay::new(4, 0, 29), "the minute became active after the 4");
487 }
488
489 #[test]
490 fn the_wheel_changes_the_segment_under_the_pointer() {
491 let mut h = harness(8, 15, false);
492 h.click(6, 0);
493 h.mouse(MouseKind::ScrollUp, 6, 0).mouse(MouseKind::ScrollUp, 6, 0);
494 assert_eq!(h.app().time, TimeOfDay::new(8, 17, 0), "one minute per notch");
495 h.mouse(MouseKind::ScrollDown, 6, 0);
496 assert_eq!(h.app().time, TimeOfDay::new(8, 16, 0));
497 }
498
499 fn active_marks(h: &Harness<Demo>) -> (Option<crate::color::Rgb>, Option<crate::color::Rgb>) {
501 (h.bg(1, 0), h.bg(6, 0))
502 }
503
504 #[test]
505 fn the_wheel_over_a_segment_changes_it_whatever_is_active() {
506 let mut h = Harness::new(Demo { time: TimeOfDay::new(8, 15, 0), seconds: false, disabled: false }, 20, 3);
507 h.click(6, 0);
508 let before = active_marks(&h);
509 h.hover(1, 0).mouse(MouseKind::ScrollUp, 1, 0);
510 assert_eq!(h.app().time, TimeOfDay::new(9, 15, 0), "hours change while the minutes are active");
511 h.mouse(MouseKind::ScrollDown, 2, 0).mouse(MouseKind::ScrollDown, 2, 0);
512 assert_eq!(h.app().time, TimeOfDay::new(7, 15, 0), "one hour per notch");
513 h.press("up");
514 assert_eq!(h.app().time, TimeOfDay::new(7, 16, 0), "the keys still change the active minutes");
515 assert_eq!(active_marks(&h), before, "the active segment and focus stay");
516 h.click(1, 2);
518 let idle = active_marks(&h);
519 h.hover(7, 0).mouse(MouseKind::ScrollUp, 7, 0);
520 assert_eq!(h.app().time, TimeOfDay::new(7, 17, 0));
521 h.hover(0, 0).mouse(MouseKind::ScrollDown, 0, 0);
522 assert_eq!(h.app().time, TimeOfDay::new(6, 17, 0), "the pillar cell belongs to the hours");
523 h.hover(1, 2);
524 assert_eq!(active_marks(&h), idle, "the field did not take focus");
525 }
526
527 #[test]
528 fn the_wheel_over_a_colon_changes_the_segment_last_hovered() {
529 let mut h = Harness::new(Demo { time: TimeOfDay::new(23, 59, 0), seconds: false, disabled: false }, 20, 3);
530 h.click(1, 0);
531 h.hover(6, 0).hover(4, 0).mouse(MouseKind::ScrollUp, 4, 0);
532 assert_eq!(h.app().time, TimeOfDay::new(23, 0, 0), "the minutes wrap like the keys");
533 h.hover(2, 0).hover(4, 0).mouse(MouseKind::ScrollUp, 4, 0);
534 assert_eq!(h.app().time, TimeOfDay::new(0, 0, 0), "the hours wrap like the keys");
535 h.hover(6, 0).hover(6, 2).hover(4, 0).mouse(MouseKind::ScrollDown, 4, 0);
537 assert_eq!(h.app().time, TimeOfDay::new(23, 0, 0));
538 }
539
540 #[test]
541 fn the_wheel_over_a_colon_without_a_hover_changes_the_active_segment() {
542 let mut h = Harness::new(Demo { time: TimeOfDay::new(8, 15, 30), seconds: true, disabled: false }, 20, 3);
543 h.press("tab").press("right").press("right");
544 h.mouse(MouseKind::ScrollUp, 4, 0);
545 assert_eq!(h.app().time, TimeOfDay::new(8, 15, 31), "straight onto the colon: the active seconds");
546 h.hover(11, 0).hover(9, 0).mouse(MouseKind::ScrollUp, 9, 0);
547 assert_eq!(h.app().time, TimeOfDay::new(8, 15, 32), "last over the seconds");
548 }
549
550 #[test]
551 fn the_wheel_does_nothing_on_a_disabled_field() {
552 let mut h = Harness::new(Demo { time: TimeOfDay::new(8, 15, 0), seconds: false, disabled: true }, 20, 1);
553 h.hover(6, 0).mouse(MouseKind::ScrollUp, 6, 0).mouse(MouseKind::ScrollUp, 1, 0);
554 assert_eq!(h.app().time, TimeOfDay::new(8, 15, 0));
555 }
556
557 #[test]
558 fn select_all_copies_cuts_and_pastes_the_whole_time() {
559 let mut h = Harness::new(Demo { time: TimeOfDay::new(9, 30, 0), seconds: false, disabled: false }, 30, 6);
560 h.set_reduced_motion(true);
561 h.press("tab").press("ctrl+c");
562 assert!(h.copied().is_empty(), "nothing is selected yet");
563 let idle = h.bg(6, 0);
564 h.press("ctrl+a");
565 let selection = h.env().theme().style("text-input-selection", None, &[]).paint("bg").map(|paint| paint.at(0.0));
566 assert_ne!(h.bg(6, 0), idle, "every segment shows the selection");
567 assert_eq!((h.bg(1, 0), h.bg(4, 0), h.bg(6, 0)), (selection, selection, selection), "colon included");
568 h.press("ctrl+c");
569 assert_eq!(h.clipboard(), Some("09:30"));
570 h.press("ctrl+a").press("ctrl+x");
571 assert_eq!(h.app().time, TimeOfDay::new(0, 0, 0));
572 h.paste("7:45");
573 assert_eq!(h.app().time, TimeOfDay::new(7, 45, 0));
574 h.paste("25:00").paste("noon");
575 assert_eq!(h.app().time, TimeOfDay::new(7, 45, 0), "text that is no time is ignored");
576 }
577
578 #[test]
579 fn right_click_opens_the_edit_menu() {
580 let mut h = Harness::new(Demo { time: TimeOfDay::new(9, 30, 0), seconds: true, disabled: false }, 30, 6);
581 h.set_reduced_motion(true);
582 h.mouse(MouseKind::Down(MouseButton::Right), 6, 0).mouse(MouseKind::Up(MouseButton::Right), 6, 0);
583 assert!(h.screen().contains("Select all"), "{}", h.screen());
584 let muted = h.env().theme().color("muted");
585 assert_eq!(h.fg(8, 2), muted, "Copy needs the whole time selected: {}", h.screen());
586 h.click_text("Select all");
587 h.mouse(MouseKind::Down(MouseButton::Right), 6, 0).mouse(MouseKind::Up(MouseButton::Right), 6, 0);
588 h.click_text("Copy");
589 assert_eq!(h.clipboard(), Some("09:30:00"));
590 h.set_system_clipboard(Some("18:05:30")).mouse(MouseKind::Down(MouseButton::Right), 1, 0);
591 h.click_text("Paste");
592 assert_eq!(h.app().time, TimeOfDay::new(18, 5, 30));
593 }
594
595 #[test]
596 fn click_activates_a_segment_and_disabled_ignores_input() {
597 let mut h = harness(8, 15, false);
598 h.click(6, 0).press("up");
599 assert_eq!(h.app().time, TimeOfDay::new(8, 16, 0));
600 let mut h = Harness::new(Demo { time: TimeOfDay::new(8, 15, 0), seconds: false, disabled: true }, 20, 1);
601 h.press("tab").press("up").click(1, 0).type_text("1");
602 assert_eq!(h.app().time, TimeOfDay::new(8, 15, 0));
603 assert_eq!(h.fg(1, 0), h.env().theme().color("muted"));
604 }
605}