//! Adding or fixing a segment by typing its times. //! //! You type clock times ("22:00", "1:30am"); the editor shows right away //! which exact moments they mean (see `time::resolve`), so a night shift //! "22:00 to 01:30" visibly ends the next day. use jiff::civil::Date; use jiff::{Timestamp, ToSpan, Zoned}; use ratatui::Frame; use ratatui::crossterm::event::KeyEvent; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use super::{field_line, message_line, window}; use crate::action::Action; use crate::domain::{Segment, WorkDay}; use crate::time::format::{parse_date, parse_time}; use crate::time::resolve::{nearest, next_after}; use crate::ui::keymap::{self, Binding, Command}; use crate::ui::widgets::text_input::{InputEvent, TextInput}; use crate::ui::{Component, Context}; /// What is being edited. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Target { /// A new segment for the work day `day`, placed after (or before) its /// existing segments. New { day: Date, after: bool }, /// Segment number `index` of the work day `day`. Existing { day: Date, index: usize, original: Segment }, /// The session in progress. Leaving the end empty keeps it running. Running { day: Date, since: Timestamp }, } pub struct SegmentEditor { target: Target, start: TextInput, end: TextInput, day: TextInput, /// The field with the focus: 0 = start, 1 = end, 2 = work day. field: usize, inserting: bool, error: Option, } /// The typed fields, turned into exact moments. struct Resolved { start: Zoned, end: Option, day: Date, } impl SegmentEditor { pub fn new(target: Target, ctx: &Context) -> SegmentEditor { let (start, end, day) = match target { Target::New { day, .. } => (String::new(), String::new(), day), Target::Existing { day, original, .. } => { (ctx.time(original.start()), ctx.time(original.end()), day) } Target::Running { day, since } => (ctx.time(since), String::new(), day), }; SegmentEditor { target, start: TextInput::new(start), end: TextInput::new(end), day: TextInput::new(day.to_string()), field: 0, inserting: true, error: None, } } fn is_running(&self) -> bool { matches!(self.target, Target::Running { .. }) } fn field_count(&self) -> usize { if self.is_running() { 2 } else { 3 } } fn input(&mut self) -> &mut TextInput { match self.field { 0 => &mut self.start, 1 => &mut self.end, _ => &mut self.day, } } /// The work day the segment is filed under (the Work day field, which /// also takes shortcuts like "yesterday", "-3" or "mon"). fn work_day(&self, ctx: &Context) -> Result { match self.target { Target::Running { day, .. } => Ok(day), _ => parse_date(self.day.text(), ctx.now.date()).map_err(|error| format!("work day: {error}")), } } /// Where typed start times are placed: close to this moment, on the /// work day `day` (the one in the Work day field). fn reference(&self, day: Date, ctx: &Context) -> Zoned { match self.target { // Next to the day's existing work, or around noon on an empty day. Target::New { after, .. } => { let work_day = ctx.day(day); let edge = if after { work_day.and_then(WorkDay::last_end) } else { work_day.and_then(WorkDay::first_start) }; edge.map_or_else(|| ctx.noon(day), |moment| ctx.local(moment)) } // Moving a segment to another work day moves its times with it, // keeping the same clock times. Target::Existing { day: original_day, original, .. } => { let moved_days = day.duration_since(original_day).as_hours() / 24; let start = ctx.local(original.start()); start.checked_add(moved_days.days()).unwrap_or(start) } Target::Running { since, .. } => ctx.local(since), } } fn resolve(&self, ctx: &Context) -> Result { let day = self.work_day(ctx)?; let start_time = parse_time(self.start.text()).map_err(|error| format!("start: {error}"))?; let start = nearest(start_time, &self.reference(day, ctx)); let end = match self.end.text().trim() { "" if self.is_running() => None, "" => return Err("end: when did it end?".into()), text => { let end_time = parse_time(text).map_err(|error| format!("end: {error}"))?; Some(next_after(end_time, &start)) } }; Ok(Resolved { start, end, day }) } fn submit(&mut self, ctx: &Context) -> Option { let result = self.resolve(ctx).and_then(|resolved| self.action(resolved)); match result { Ok(action) => Some(action), Err(error) => { self.error = Some(error); None } } } fn action(&self, resolved: Resolved) -> Result { let start = resolved.start.timestamp(); let end = resolved.end.map(|end| end.timestamp()); if let Target::Running { .. } = self.target { return Ok(Action::FixRunning { since: start, stopped: end }); } let segment = Segment::new(start, end.ok_or("end: when did it end?")?).map_err(|error| error.to_string())?; Ok(match self.target { Target::Existing { day, index, .. } => { Action::ReplaceSegment { day, index, segment, new_day: resolved.day } } _ => Action::AddSegment { day: resolved.day, segment }, }) } fn title(&self, ctx: &Context) -> String { match self.target { Target::New { day, .. } => format!("Add work · {}", ctx.fmt.date_short(day)), Target::Existing { day, index, .. } => { format!("Segment {} · {}", index + 1, ctx.fmt.date_short(day)) } Target::Running { .. } => "The session in progress".to_string(), } } /// The exact moment a field means, next to it. fn note(moment: Option<&Zoned>, empty_note: &str, ctx: &Context) -> Span<'static> { match moment { Some(moment) => Span::styled( format!("{} {}", ctx.fmt.date_short(moment.date()), ctx.fmt.time(moment.time())), ctx.theme.accent(), ), None => Span::styled(empty_note.to_string(), ctx.theme.muted()), } } } impl Component for SegmentEditor { fn keys(&self) -> &'static [&'static [Binding]] { &[keymap::POPUP] } fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &Context) { let title = self.title(ctx); let inner = window(frame, area, 76, 11, &title, "Enter save · Tab next field · Esc cancel", ctx.theme); let resolved = self.resolve(ctx); let (start, end) = match &resolved { Ok(resolved) => (Some(&resolved.start), resolved.end.as_ref()), Err(_) => (None, None), }; let end_note = if self.is_running() { "empty = still working" } else { "" }; let mut lines = vec![ Line::default(), field_line( "Start", &self.start, self.field == 0, self.inserting, Self::note(start, "", ctx), ctx.theme, ), field_line( "End", &self.end, self.field == 1, self.inserting, Self::note(end, end_note, ctx), ctx.theme, ), ]; if !self.is_running() { // Which date the field means ("yesterday" → Wed Sep 23). let note = match self.work_day(ctx) { Ok(day) => Span::styled(ctx.fmt.date_short(day), ctx.theme.accent()), Err(_) => Span::styled("a date, yesterday, -3, mon...", ctx.theme.muted()), }; lines.push(field_line("Work day", &self.day, self.field == 2, self.inserting, note, ctx.theme)); } lines.push(Line::default()); let length = match (start, end) { (Some(start), Some(end)) => ctx.fmt.duration(end.duration_since(start)), (Some(start), None) if self.is_running() => { ctx.fmt.duration(ctx.now.duration_since(start)) + " so far" } _ => String::new(), }; lines.push(Line::from(vec![ Span::raw(" Length "), Span::styled(length, Style::new().add_modifier(Modifier::BOLD)), ])); let tip = "22:30, 2230 or 10:30pm all work. An end before the start is the next day."; lines.push(message_line(self.error.as_deref(), tip, ctx.theme)); frame.render_widget(Paragraph::new(lines), inner); } fn command(&mut self, command: Command, _count: Option, ctx: &Context) -> Option { match command { Command::Confirm => return self.submit(ctx), Command::Close => return Some(Action::ClosePopup), Command::Down => self.field = (self.field + 1) % self.field_count(), Command::Up => self.field = (self.field + self.field_count() - 1) % self.field_count(), Command::Top => self.field = 0, Command::Bottom => self.field = self.field_count() - 1, Command::Insert => { self.input().cursor_to_end(); self.inserting = true; } Command::InsertAtStart => { self.input().cursor_to_start(); self.inserting = true; } Command::Retype => { self.input().clear(); self.inserting = true; } _ => {} } None } fn inserting(&self) -> bool { self.inserting } fn insert_key(&mut self, key: KeyEvent, ctx: &Context) -> Option { self.error = None; match self.input().insert_key(key) { InputEvent::Submit => return self.submit(ctx), InputEvent::Exit => self.inserting = false, InputEvent::Next => self.field = (self.field + 1) % self.field_count(), InputEvent::Previous => self.field = (self.field + self.field_count() - 1) % self.field_count(), InputEvent::Edited => {} } None } }