1mod parse;
4#[cfg(test)]
5mod tests;
6
7use std::time::Duration;
8
9pub use parse::{DurationError, DurationUnit, parse_duration};
10
11use super::edit_menu::{self, EditAction, TextMenu};
12use crate::event::{Event, KeyEvent, MouseButton, MouseKind};
13use crate::geometry::{Rect, Size};
14use crate::i18n::I18n;
15use crate::keymap::{Key, Modifiers, Scope};
16use crate::style::CellStyle;
17use crate::text;
18use crate::theme::State;
19use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
20use parse::LONGEST;
21
22type DurationMessage<Msg> = Box<dyn Fn(Duration) -> Msg>;
24
25type RejectMessage<Msg> = Box<dyn Fn(DurationError) -> Msg>;
27
28fn parts(total: u64) -> [u64; 3] {
30 [total / 3600, total / 60 % 60, total % 60]
31}
32
33fn with_part(total: u64, index: usize, value: u64) -> u64 {
37 let mut parts = parts(total);
38 parts[index] = value;
39 (parts[0] * 3600 + parts[1] * 60 + parts[2]).min(LONGEST)
40}
41
42fn digits(value: u64) -> u16 {
44 u16::try_from(value.checked_ilog10().unwrap_or(0) + 1).unwrap_or(u16::MAX)
45}
46
47#[derive(Debug, PartialEq, Eq)]
49struct Layout {
50 segments: Vec<(u16, u16)>,
52 marks: Vec<(u16, String)>,
54 width: u16,
55}
56
57pub struct DurationInput<Msg> {
92 value: Duration,
93 seconds: bool,
94 invalid: bool,
95 disabled: bool,
96 on_change: Option<DurationMessage<Msg>>,
97 on_reject: Option<RejectMessage<Msg>>,
98}
99
100#[derive(Debug, Default)]
101struct DurationMemory {
102 segment: usize,
104 pending: Option<u8>,
106 all: bool,
108 hovered: Option<usize>,
111}
112
113impl<Msg> DurationInput<Msg> {
114 #[must_use]
117 pub fn new(value: Duration) -> Self {
118 Self { value, seconds: false, invalid: false, disabled: false, on_change: None, on_reject: None }
119 }
120
121 #[must_use]
123 pub fn seconds(mut self, seconds: bool) -> Self {
124 self.seconds = seconds;
125 self
126 }
127
128 #[must_use]
130 pub fn invalid(mut self, invalid: bool) -> Self {
131 self.invalid = invalid;
132 self
133 }
134
135 #[must_use]
137 pub fn disabled(mut self, disabled: bool) -> Self {
138 self.disabled = disabled;
139 self
140 }
141
142 #[must_use]
144 pub fn on_change(mut self, message: impl Fn(Duration) -> Msg + 'static) -> Self {
145 self.on_change = Some(Box::new(message));
146 self
147 }
148
149 #[must_use]
153 pub fn on_reject(mut self, message: impl Fn(DurationError) -> Msg + 'static) -> Self {
154 self.on_reject = Some(Box::new(message));
155 self
156 }
157
158 fn active(&self) -> bool {
159 !self.disabled && self.on_change.is_some()
160 }
161
162 fn count(&self) -> usize {
163 if self.seconds { 3 } else { 2 }
164 }
165
166 fn total(&self) -> u64 {
167 self.value.as_secs()
168 }
169
170 fn layout(&self, words: Option<&I18n>) -> Layout {
172 let parts = parts(self.total());
173 let mut segments = Vec::new();
174 let mut marks = Vec::new();
175 let mut x: u16 = 0;
176 for (index, unit) in DurationUnit::ALL[..self.count()].iter().enumerate() {
177 if words.is_none() && index > 0 {
178 marks.push((x, ":".to_owned()));
179 x = x.saturating_add(1);
180 }
181 let width = if index == 0 { digits(parts[0]).max(2) } else { 2 };
183 segments.push((x, width + 2));
184 x = x.saturating_add(width + 2);
185 if let Some(i18n) = words {
186 let word = unit.short(i18n);
187 let width = text::width(&word);
188 marks.push((x, word));
189 x = x.saturating_add(width);
190 }
191 }
192 Layout { segments, marks, width: x }
193 }
194
195 fn fitted(&self, i18n: &I18n, width: u16) -> Layout {
197 let full = self.layout(Some(i18n));
198 if full.width <= width { full } else { self.layout(None) }
199 }
200
201 fn change(&self, cx: &mut EventCx<'_, Msg>, total: u64) {
202 if total != self.total()
203 && let Some(message) = &self.on_change
204 {
205 cx.emit(message(Duration::from_secs(total)));
206 }
207 }
208
209 fn type_digit(&self, cx: &mut EventCx<'_, Msg>, digit: u8) {
211 let last = self.count() - 1;
212 let memory = cx.memory::<DurationMemory>();
213 let segment = memory.segment.min(last);
214 let (value, complete) = match memory.pending {
215 Some(first) => (first * 10 + digit, true),
216 None => (digit, false),
217 };
218 memory.pending = if complete { None } else { Some(digit) };
219 if complete && segment < last {
220 memory.segment = segment + 1;
221 }
222 self.change(cx, with_part(self.total(), segment, u64::from(value)));
223 }
224
225 fn step(&self, cx: &mut EventCx<'_, Msg>, segment: usize, up: bool) {
228 let last = self.count() - 1;
229 let memory = cx.memory::<DurationMemory>();
230 if segment == memory.segment.min(last) {
232 memory.pending = None;
233 }
234 let unit = DurationUnit::ALL[segment].seconds();
235 let total = self.total();
236 let next = if up { (total + unit).min(LONGEST.max(total)) } else { total.saturating_sub(unit) };
237 self.change(cx, next);
238 }
239
240 fn segment_at(layout: &Layout, offset: i32) -> Option<usize> {
243 layout.segments.iter().position(|(start, width)| {
244 let start = i32::from(*start);
245 (start..start + i32::from(*width)).contains(&offset)
246 })
247 }
248
249 fn event_layout(&self, cx: &EventCx<'_, Msg>) -> Layout {
251 self.fitted(cx.env().i18n(), cx.area().width)
252 }
253
254 fn wheel_target(&self, cx: &mut EventCx<'_, Msg>, x: i32) -> usize {
257 let last = self.count() - 1;
258 let under = Self::segment_at(&self.event_layout(cx), x - cx.area().x);
259 let memory = cx.memory::<DurationMemory>();
260 if under.is_some() {
261 memory.hovered = under;
262 }
263 under.or(memory.hovered).unwrap_or(memory.segment).min(last)
264 }
265
266 fn activate_at(&self, cx: &mut EventCx<'_, Msg>, x: i32) {
268 let offset = x - cx.area().x;
269 let layout = self.event_layout(cx);
270 let index = layout.segments.iter().rposition(|(start, _)| offset >= i32::from(*start)).unwrap_or(0);
271 let memory = cx.memory::<DurationMemory>();
272 memory.segment = index;
273 memory.pending = None;
274 }
275
276 fn apply(&self, cx: &mut EventCx<'_, Msg>, action: EditAction) {
278 let all = std::mem::take(&mut cx.memory::<DurationMemory>().all);
279 match action {
280 EditAction::Cut | EditAction::Copy if all => {
281 cx.copy(parse::write(self.value, self.seconds, cx.env().i18n()));
282 if action == EditAction::Cut {
283 self.change(cx, 0);
284 }
285 }
286 EditAction::Cut | EditAction::Copy => {}
287 EditAction::Paste => cx.run_action(Scope::Global, "paste"),
288 EditAction::SelectAll => cx.memory::<DurationMemory>().all = true,
289 }
290 }
291
292 fn paste(&self, cx: &mut EventCx<'_, Msg>, text: &str) -> bool {
295 cx.memory::<DurationMemory>().all = false;
296 match parse_duration(text, cx.env().i18n()) {
297 Ok(length) => {
298 let pasted = length.as_secs();
299 let total = if self.seconds { pasted } else { with_part(pasted, 2, parts(self.total())[2]) };
300 self.change(cx, total);
301 true
302 }
303 Err(error) => match &self.on_reject {
304 Some(message) => {
305 cx.emit(message(error));
306 true
307 }
308 None => false,
309 },
310 }
311 }
312
313 fn chord_action(key: &KeyEvent) -> Option<EditAction> {
315 let ctrl = Modifiers { ctrl: true, ..Modifiers::default() };
316 match key.chord.key {
317 Key::Char('a') if key.chord.mods == ctrl => Some(EditAction::SelectAll),
318 Key::Char('c') if key.chord.mods == ctrl => Some(EditAction::Copy),
319 Key::Char('x') if key.chord.mods == ctrl => Some(EditAction::Cut),
320 _ => None,
321 }
322 }
323
324 fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
327 let open = edit_menu::is_open(cx);
328 if !open && !edit_menu::asks(event) {
329 return false;
330 }
331 if let Event::Mouse(mouse) = event
332 && !open
333 && !cx.memory::<DurationMemory>().all
334 {
335 self.activate_at(cx, mouse.x);
336 }
337 let all = cx.memory::<DurationMemory>().all;
338 let (used, chosen) = TextMenu::edit(cx.env(), all, cx.can_paste()).event(cx, event);
339 if used && !open {
340 cx.probe_clipboard();
341 }
342 if let Some(action) = chosen {
343 self.apply(cx, action);
344 }
345 used
346 }
347
348 fn key(&self, cx: &mut EventCx<'_, Msg>, key: &KeyEvent) -> bool {
350 let last = self.count() - 1;
351 cx.memory::<DurationMemory>().all = false;
352 let segment = cx.memory::<DurationMemory>().segment.min(last);
353 if key.is_plain(Key::Left) || key.is_plain(Key::Right) {
354 let memory = cx.memory::<DurationMemory>();
355 memory.segment = if key.is_plain(Key::Left) { segment.saturating_sub(1) } else { (segment + 1).min(last) };
356 memory.pending = None;
357 return true;
358 }
359 if key.is_plain(Key::Up) || key.is_plain(Key::Down) {
360 self.step(cx, segment, key.is_plain(Key::Up));
361 return true;
362 }
363 if key.is_plain(Key::Backspace) {
364 cx.memory::<DurationMemory>().pending = None;
365 self.change(cx, with_part(self.total(), segment, 0));
366 return true;
367 }
368 if key.is_plain(Key::Space) || key.text == Some(':') {
369 let memory = cx.memory::<DurationMemory>();
370 memory.segment = (segment + 1).min(last);
371 memory.pending = None;
372 return true;
373 }
374 match key.text {
375 Some(c) if c.is_ascii_digit() => {
376 self.type_digit(cx, u8::try_from(c.to_digit(10).unwrap_or(0)).unwrap_or(0));
377 true
378 }
379 _ => false,
380 }
381 }
382}
383
384impl<Msg: 'static> Widget<Msg> for DurationInput<Msg> {
385 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
386 let full = self.layout(Some(cx.env().i18n()));
387 Size::new(full.width, 1).min(available)
388 }
389
390 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
391 let mut states = if self.active() { cx.states() } else { Vec::new() };
392 if self.invalid {
393 states.push(State::Invalid);
394 }
395 if self.disabled {
396 states.push(State::Disabled);
397 }
398 let focused = states.contains(&State::Focus);
399 let layout = self.fitted(cx.env().i18n(), area.width);
400 let pointer = cx.pointer().map(|(x, _)| Self::segment_at(&layout, x - area.x));
403 let (segment, pending, all) = {
404 let memory = cx.memory::<DurationMemory>();
405 if !focused {
406 *memory = DurationMemory { hovered: memory.hovered, ..DurationMemory::default() };
407 }
408 match pointer {
409 None => memory.hovered = None,
410 Some(Some(index)) => memory.hovered = Some(index),
411 Some(None) => {}
412 }
413 (memory.segment, memory.pending, memory.all)
414 };
415 let selection = cx.style("text-input-selection", None, &states).text();
416 let field_style = cx.style("time-input", None, &states);
417 let surface = field_style.text();
418 let field = Rect::new(area.x, area.y, layout.width.min(area.width), 1);
419 cx.clear(field, surface.bg.unwrap_or_else(|| cx.color("raised")));
420 let pillar = field_style.color("pillar");
421 if self.active() {
422 cx.register_hit(field);
423 edit_menu::request_overlay(cx, field);
424 }
425 let separator = cx.style("time-separator", None, &states).text();
426 if let Some(bg) = selection.bg.filter(|_| all) {
429 cx.fill(field, bg);
430 }
431 for (x, mark) in &layout.marks {
432 let x = field.x + i32::from(*x);
433 let room = u16::try_from(field.right() - x).unwrap_or(0);
434 let fg = if all { selection.fg.or(separator.fg) } else { separator.fg };
435 cx.text(x, field.y, mark, CellStyle { bg: None, fg, ..separator }, room);
436 }
437 let resting = !states.iter().any(|state| matches!(state, State::Hover | State::Focus));
439 let empty = self.total() == 0 && resting && !all;
440 let parts = parts(self.total());
441 for (index, (offset, width)) in layout.segments.iter().enumerate() {
442 let x = field.x + i32::from(*offset);
443 let mut segment_states = states.clone();
444 if focused && index == segment && !all {
445 segment_states.push(State::Selected);
446 if pending.is_some() {
447 segment_states.push(State::Active);
448 }
449 }
450 let mut style = cx.style("time-segment", None, &segment_states).text();
451 if all {
452 style.bg = None;
453 style.fg = selection.fg.or(style.fg);
454 }
455 let rect = Rect::new(x, field.y, *width, 1).intersect(field);
456 if let Some(bg) = style.bg {
457 cx.clear(rect, bg);
458 }
459 let digits = if index == 0 {
460 format!("{:>width$}", parts[0], width = usize::from(width - 2))
461 } else {
462 format!("{:02}", parts[index])
463 };
464 let fg = if empty { separator.fg } else { style.fg.or(surface.fg) };
465 let text_style = CellStyle { bg: None, fg, ..style };
466 cx.text(x + 1, field.y, &digits, text_style, text::width(&digits).min(rect.width.saturating_sub(1)));
467 }
468 if let Some(color) = pillar {
470 cx.pillar(field.x, field.y, color);
471 }
472 }
473
474 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
475 let all = cx.memory::<DurationMemory>().all;
476 TextMenu::edit(cx.env(), all, cx.can_paste()).paint_overlay(cx, anchor);
477 }
478
479 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
480 if !self.active() {
481 return false;
482 }
483 if self.menu_event(cx, event) {
484 return true;
485 }
486 match event {
487 Event::Key(key) if let Some(action) = Self::chord_action(key) => {
488 self.apply(cx, action);
489 true
490 }
491 Event::Paste(text) => self.paste(cx, text),
492 Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp | MouseKind::ScrollDown) => {
493 let up = mouse.kind == MouseKind::ScrollUp;
494 let segment = self.wheel_target(cx, mouse.x);
495 self.step(cx, segment, up);
496 true
497 }
498 Event::Key(key) => self.key(cx, key),
499 Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Left) => {
500 cx.memory::<DurationMemory>().all = false;
501 self.activate_at(cx, mouse.x);
502 true
503 }
504 _ => false,
505 }
506 }
507
508 fn focusable(&self) -> bool {
509 self.active()
510 }
511}