diff --git a/src/app.rs b/src/app.rs index 057a6dc..508b81a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,6 +9,7 @@ use crate::{ widgets::{ clock::{self, ClockState, ClockStateArgs}, countdown::{Countdown, CountdownState, CountdownStateArgs}, + event::{EventState, EventStateArgs, EventWidget}, footer::{Footer, FooterState}, header::Header, local_time::{LocalTimeState, LocalTimeStateArgs, LocalTimeWidget}, @@ -29,6 +30,7 @@ use ratatui::{ }; use std::path::PathBuf; use std::time::Duration; +use time::macros::format_description; use tracing::{debug, error}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -49,6 +51,7 @@ pub struct App { countdown: CountdownState, timer: TimerState, pomodoro: PomodoroState, + event: EventState, local_time: LocalTimeState, style: Style, with_decis: bool, @@ -223,6 +226,17 @@ impl App { app_time, app_time_format, }), + event: EventState::new(EventStateArgs { + app_time, + event_time: time::PrimitiveDateTime::parse( + "2030-10-03 15:00:00", + format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"), + ) + .unwrap(), + event_title: "My event".to_owned(), + with_decis, + app_tx: app_tx.clone(), + }), footer: FooterState::new( show_menu, if footer_toggle_app_time == Toggle::On { @@ -247,6 +261,9 @@ impl App { KeyCode::Char('c') => app.content = Content::Countdown, KeyCode::Char('t') => app.content = Content::Timer, KeyCode::Char('p') => app.content = Content::Pomodoro, + // TODO(#102) Before we can use `e` here + // we do need to change keybindings for editing. + KeyCode::Char('z') => app.content = Content::Event, KeyCode::Char('l') => app.content = Content::LocalTime, // toogle app time format KeyCode::Char(':') => { @@ -291,6 +308,7 @@ impl App { app.timer.set_with_decis(app.with_decis); app.countdown.set_with_decis(app.with_decis); app.pomodoro.set_with_decis(app.with_decis); + app.event.set_with_decis(app.with_decis); } KeyCode::Up => app.footer.set_show_menu(true), KeyCode::Down => app.footer.set_show_menu(false), @@ -303,6 +321,7 @@ impl App { app.app_time = AppTime::new(); app.countdown.set_app_time(app.app_time); app.local_time.set_app_time(app.app_time); + app.event.set_app_time(app.app_time); } // Pipe events into subviews and handle only 'unhandled' events afterwards @@ -310,6 +329,7 @@ impl App { Content::Countdown => app.countdown.update(event.clone()), Content::Timer => app.timer.update(event.clone()), Content::Pomodoro => app.pomodoro.update(event.clone()), + Content::Event => app.event.update(event.clone()), Content::LocalTime => app.local_time.update(event.clone()), } { match unhandled { @@ -401,6 +421,7 @@ impl App { AppEditMode::None } } + Content::Event => AppEditMode::None, Content::LocalTime => AppEditMode::None, } } @@ -410,6 +431,7 @@ impl App { Content::Countdown => self.countdown.is_running(), Content::Timer => self.timer.get_clock().is_running(), Content::Pomodoro => self.pomodoro.get_clock().is_running(), + Content::Event => self.event.get_clock().is_running(), // `LocalTime` does not use a `Clock` Content::LocalTime => false, } @@ -420,6 +442,7 @@ impl App { Content::Countdown => Some(self.countdown.get_clock().get_percentage_done()), Content::Timer => None, Content::Pomodoro => Some(self.pomodoro.get_clock().get_percentage_done()), + Content::Event => Some(self.event.get_percentage_done()), Content::LocalTime => None, } } @@ -483,6 +506,11 @@ impl AppWidget { blink: state.blink == Toggle::On, } .render(area, buf, &mut state.pomodoro), + Content::Event => EventWidget { + style: state.style, + blink: state.blink == Toggle::On, + } + .render(area, buf, &mut state.event), Content::LocalTime => { LocalTimeWidget { style: state.style }.render(area, buf, &mut state.local_time); } diff --git a/src/common.rs b/src/common.rs index 0365851..d1d6c3a 100644 --- a/src/common.rs +++ b/src/common.rs @@ -15,6 +15,8 @@ pub enum Content { Timer, #[value(name = "pomodoro", alias = "p")] Pomodoro, + #[value(name = "event", alias = "e")] + Event, #[value(name = "localtime", alias = "l")] LocalTime, } diff --git a/src/duration.rs b/src/duration.rs index f98fce4..49af28f 100644 --- a/src/duration.rs +++ b/src/duration.rs @@ -5,6 +5,7 @@ use color_eyre::{ use std::cmp::min; use std::fmt; use std::time::Duration; +use time::OffsetDateTime; use crate::common::AppTime; @@ -46,6 +47,29 @@ pub enum DirectedDuration { Since(Duration), } +impl From for Duration { + fn from(directed: DirectedDuration) -> Self { + match directed { + DirectedDuration::Until(d) => d, + DirectedDuration::Since(d) => d, + } + } +} + +impl DirectedDuration { + pub fn from_offset_date_times(value_a: OffsetDateTime, value_b: OffsetDateTime) -> Self { + let diff = value_a - value_b; + + if diff.is_negative() { + Self::Since(Duration::from_millis( + diff.whole_milliseconds().unsigned_abs() as u64, + )) + } else { + Self::Until(Duration::from_millis(diff.whole_milliseconds() as u64)) + } + } +} + #[derive(Debug, Clone, Copy, PartialOrd)] pub struct DurationEx { inner: Duration, @@ -472,6 +496,31 @@ mod tests { assert_eq!(result, 1); } + #[test] + fn test_from_offset_date_times() { + use time::macros::datetime; + + // Future time (Until) + let now = datetime!(2024-01-01 12:00:00).assume_utc(); + let future = datetime!(2024-01-01 13:00:00).assume_utc(); + assert!(matches!( + DirectedDuration::from_offset_date_times(future, now), + DirectedDuration::Until(_) + )); + + // Past time (Since) + assert!(matches!( + DirectedDuration::from_offset_date_times(now, future), + DirectedDuration::Since(_) + )); + + // Same time (Until with 0 duration) + assert!(matches!( + DirectedDuration::from_offset_date_times(now, now), + DirectedDuration::Until(_) + )); + } + #[test] fn test_parse_duration() { // ss diff --git a/src/widgets.rs b/src/widgets.rs index ae6bae4..d3d5f74 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -6,6 +6,7 @@ pub mod clock_elements_test; pub mod clock_test; pub mod countdown; pub mod edit_time; +pub mod event; pub mod footer; pub mod header; pub mod local_time; diff --git a/src/widgets/event.rs b/src/widgets/event.rs new file mode 100644 index 0000000..d99bfab --- /dev/null +++ b/src/widgets/event.rs @@ -0,0 +1,156 @@ +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Layout, Rect}, + text::Line, + widgets::{StatefulWidget, Widget}, +}; +use time::{OffsetDateTime, macros::format_description}; + +use crate::{ + common::{AppTime, Style}, + constants::TICK_VALUE_MS, + duration::DirectedDuration, + events::{AppEventTx, TuiEvent, TuiEventHandler}, + utils::center, + widgets::clock::{self, ClockState, ClockStateArgs, ClockWidget}, +}; +use std::{cmp::max, time::Duration}; + +/// State for `EventWidget` +pub struct EventState { + title: String, + event_time: OffsetDateTime, + clock: ClockState, + directed_duration: DirectedDuration, +} + +pub struct EventStateArgs { + pub app_time: AppTime, + pub event_time: time::PrimitiveDateTime, + pub event_title: String, + pub with_decis: bool, + pub app_tx: AppEventTx, +} + +impl EventState { + pub fn new(args: EventStateArgs) -> Self { + let EventStateArgs { + app_time, + event_time, + event_title, + with_decis, + app_tx, + } = args; + + let app_datetime = OffsetDateTime::from(app_time); + // assume event has as same `offset` as `app_time` + let event_offset = event_time.assume_offset(app_datetime.offset()); + let directed_duration = + DirectedDuration::from_offset_date_times(event_offset, app_datetime); + let current_value = directed_duration.into(); + + let clock = ClockState::::new(ClockStateArgs { + initial_value: current_value, + current_value, + tick_value: Duration::from_millis(TICK_VALUE_MS), + with_decis, + app_tx: Some(app_tx.clone()), + }); + + Self { + title: event_title, + event_time: event_offset, + directed_duration, + clock, + } + } + + pub fn get_clock(&self) -> &ClockState { + &self.clock + } + + pub fn set_app_time(&mut self, app_time: AppTime) { + // update `directed_duration` + let app_datetime = OffsetDateTime::from(app_time); + self.directed_duration = + DirectedDuration::from_offset_date_times(self.event_time, app_datetime); + // update clock + let duration: Duration = self.directed_duration.into(); + self.clock.set_current_value(duration.into()); + } + + pub fn set_with_decis(&mut self, with_decis: bool) { + self.clock.with_decis = with_decis; + } + + pub fn get_percentage_done(&self) -> u16 { + match self.directed_duration { + DirectedDuration::Since(_) => 100, + DirectedDuration::Until(_) => self.clock.get_percentage_done(), + } + } +} + +impl TuiEventHandler for EventState { + fn update(&mut self, event: TuiEvent) -> Option { + Some(event) + } +} + +#[derive(Debug)] +pub struct EventWidget { + pub style: Style, + pub blink: bool, +} + +impl StatefulWidget for EventWidget { + type State = EventState; + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let clock = &mut state.clock; + let clock_widget = ClockWidget::new(self.style, self.blink); + let label_event = Line::raw(state.title.to_uppercase()); + let time_str = state + .event_time + .format(&format_description!( + "[year]-[month]-[day] [hour]:[minute]:[second]" + )) + .unwrap_or_else(|e| format!("time format error: {}", e)); + let time_prefix = match state.directed_duration { + DirectedDuration::Since(d) => { + // Show `done` for a short of time (1 sec.) + if d < Duration::from_secs(1) { + "Done" + } else { + "Since" + } + } + DirectedDuration::Until(_) => "Until", + }; + let label_time = Line::raw(format!( + "{} {}", + time_prefix.to_uppercase(), + time_str.to_uppercase() + )); + let max_label_width = max(label_event.width(), label_time.width()) as u16; + + let area = center( + area, + Constraint::Length(max( + clock_widget.get_width(clock.get_format(), clock.with_decis), + max_label_width, + )), + Constraint::Length(clock_widget.get_height() + 3 /* height of label */), + ); + let [_, v1, v2, v3] = Layout::vertical(Constraint::from_lengths([ + 1, // empty (offset) to keep everything centered vertically comparing to "clock" widgets with one label only + clock_widget.get_height(), + 1, // event date + 1, // event title + ])) + .areas(area); + + clock_widget.render(v1, buf, clock); + label_time.centered().render(v2, buf); + label_event.centered().render(v3, buf); + } +}