diff --git a/src/app.rs b/src/app.rs index f795cbd..08bd3c2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,6 +10,7 @@ use crate::{ countdown::{Countdown, CountdownState, CountdownStateArgs}, footer::{Footer, FooterState}, header::Header, + local_time::{LocalTimeState, LocalTimeStateArgs, LocalTimeWidget}, pomodoro::{Mode as PomodoroMode, PomodoroState, PomodoroStateArgs, PomodoroWidget}, timer::{Timer, TimerState}, }, @@ -48,6 +49,7 @@ pub struct App { countdown: CountdownState, timer: TimerState, pomodoro: PomodoroState, + local_time: LocalTimeState, style: Style, with_decis: bool, footer: FooterState, @@ -209,6 +211,10 @@ impl App { round: pomodoro_round, app_tx: app_tx.clone(), }), + local_time: LocalTimeState::new(LocalTimeStateArgs { + app_time, + app_time_format, + }), footer: FooterState::new( show_menu, if footer_toggle_app_time == Toggle::On { @@ -233,26 +239,38 @@ impl App { KeyCode::Char('c') => app.content = Content::Countdown, KeyCode::Char('t') => app.content = Content::Timer, KeyCode::Char('p') => app.content = Content::Pomodoro, + KeyCode::Char('l') => app.content = Content::LocalTime, // toogle app time format KeyCode::Char(':') => { - // - // TODO: Check content != LocalClock - let new_format = match app.footer.app_time_format() { - // footer is hidden in footer -> - None => Some(AppTimeFormat::first()), - Some(v) => { - if v != &AppTimeFormat::last() { - Some(v.next()) - } else { - None - } + if app.content == Content::LocalTime { + // For LocalTime content: just cycle through formats + app.app_time_format = app.app_time_format.next(); + app.local_time.set_app_time_format(app.app_time_format); + // Only update footer if it's currently showing time + if app.footer.app_time_format().is_some() { + app.footer.set_app_time_format(Some(app.app_time_format)); } - }; + } else { + // For other content: allow footer to toggle between formats and None + let new_format = match app.footer.app_time_format() { + // footer is hidden -> show first format + None => Some(AppTimeFormat::first()), + Some(v) => { + if v != &AppTimeFormat::last() { + Some(v.next()) + } else { + // reached last format -> hide footer time + None + } + } + }; - if let Some(format) = new_format { - app.app_time_format = format; + if let Some(format) = new_format { + app.app_time_format = format; + app.local_time.set_app_time_format(format); + } + app.footer.set_app_time_format(new_format); } - app.footer.set_app_time_format(new_format); } // toogle menu KeyCode::Char('m') => app.footer.set_show_menu(!app.footer.get_show_menu()), @@ -276,6 +294,7 @@ impl App { if matches!(event, events::TuiEvent::Tick) { app.app_time = get_app_time(); app.countdown.set_app_time(app.app_time); + app.local_time.set_app_time(app.app_time); } // Pipe events into subviews and handle only 'unhandled' events afterwards @@ -283,6 +302,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::LocalTime => app.local_time.update(event.clone()), } { match unhandled { events::TuiEvent::Render | events::TuiEvent::Resize => { @@ -373,6 +393,7 @@ impl App { AppEditMode::None } } + Content::LocalTime => AppEditMode::None, } } @@ -381,6 +402,8 @@ impl App { Content::Countdown => self.countdown.is_running(), Content::Timer => self.timer.get_clock().is_running(), Content::Pomodoro => self.pomodoro.get_clock().is_running(), + // `LocalTime` does not use a `Clock` + Content::LocalTime => false, } } @@ -389,6 +412,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::LocalTime => None, } } @@ -451,6 +475,9 @@ impl AppWidget { blink: state.blink == Toggle::On, } .render(area, buf, &mut state.pomodoro), + Content::LocalTime => { + LocalTimeWidget { style: state.style }.render(area, buf, &mut state.local_time); + } }; } } diff --git a/src/common.rs b/src/common.rs index b9327a9..5da1562 100644 --- a/src/common.rs +++ b/src/common.rs @@ -2,8 +2,7 @@ use clap::ValueEnum; use ratatui::symbols::shade; use serde::{Deserialize, Serialize}; use strum::EnumString; -use time::OffsetDateTime; -use time::format_description; +use time::{OffsetDateTime, format_description}; #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Default, Serialize, Deserialize, @@ -16,8 +15,8 @@ pub enum Content { Timer, #[value(name = "pomodoro", alias = "p")] Pomodoro, - // #[value(name = "localclock", alias = "l")] - // LocalClock, + #[value(name = "localtime", alias = "l")] + LocalTime, } #[derive(Clone, Debug)] @@ -135,6 +134,30 @@ impl AppTime { }) .unwrap_or_else(|e| e.to_string()) } + + pub fn get_period(&self) -> String { + format_description::parse("[period]") + .map_err(|_| "parse error") + .and_then(|fd| { + OffsetDateTime::from(*self) + .format(&fd) + .map_err(|_| "format error") + }) + .unwrap_or_else(|e| e.to_string()) + } + + /// Converts `AppTime` into a `Duration` representing elapsed time since midnight (today). + pub fn as_duration_of_today(&self) -> std::time::Duration { + let dt = OffsetDateTime::from(*self); + let time = dt.time(); + + let total_nanos = u64::from(time.hour()) * 3_600_000_000_000 + + u64::from(time.minute()) * 60_000_000_000 + + u64::from(time.second()) * 1_000_000_000 + + u64::from(time.nanosecond()); + + std::time::Duration::from_nanos(total_nanos) + } } #[derive(Debug)] diff --git a/src/duration.rs b/src/duration.rs index d02c174..6ae65d6 100644 --- a/src/duration.rs +++ b/src/duration.rs @@ -60,10 +60,19 @@ impl DurationEx { self.seconds() / (SECS_PER_MINUTE * MINS_PER_HOUR) } + /// Hours as 24-hour clock pub fn hours_mod(&self) -> u64 { self.hours() % HOURS_PER_DAY } + /// Hours as 12-hour clock + pub fn hours_mod_12(&self) -> u64 { + // 0 => 12, + // 1..=12 => hours, + // 13..=23 => hours - 12, + (self.hours_mod() + 11) % 12 + 1 + } + pub fn minutes(&self) -> u64 { self.seconds() / MINS_PER_HOUR } @@ -211,6 +220,34 @@ mod tests { assert_eq!(format!("{ex3}"), "11"); } + #[test] + fn test_hours_mod_12() { + // 24 -> 12 + let ex: DurationEx = ONE_HOUR.saturating_mul(24).into(); + let result = ex.hours_mod_12(); + assert_eq!(result, 12); + + // 12 -> 12 + let ex: DurationEx = ONE_HOUR.saturating_mul(12).into(); + let result = ex.hours_mod_12(); + assert_eq!(result, 12); + + // 0 -> 12 + let ex: DurationEx = ONE_SECOND.into(); + let result = ex.hours_mod_12(); + assert_eq!(result, 12); + + // 13 -> 1 + let ex: DurationEx = ONE_HOUR.saturating_mul(13).into(); + let result = ex.hours_mod_12(); + assert_eq!(result, 1); + + // 1 -> 1 + let ex: DurationEx = ONE_HOUR.saturating_mul(1).into(); + let result = ex.hours_mod_12(); + assert_eq!(result, 1); + } + #[test] fn test_parse_duration() { // ss diff --git a/src/widgets.rs b/src/widgets.rs index acfc924..ae6bae4 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -8,6 +8,7 @@ pub mod countdown; pub mod edit_time; pub mod footer; pub mod header; +pub mod local_time; pub mod pomodoro; pub mod progressbar; pub mod timer; diff --git a/src/widgets/footer.rs b/src/widgets/footer.rs index b6cda48..b825803 100644 --- a/src/widgets/footer.rs +++ b/src/widgets/footer.rs @@ -56,6 +56,7 @@ impl StatefulWidget for Footer { (Content::Countdown, "[c]ountdown"), (Content::Timer, "[t]imer"), (Content::Pomodoro, "[p]omodoro"), + (Content::LocalTime, "[l]ocal time"), ]); let [_, area] = @@ -71,11 +72,12 @@ impl StatefulWidget for Footer { ) .title( Line::from( - match state.app_time_format { - // `Hidden` -> no (empty) title - None => "".into(), - // others -> add some space around - Some(v) => format!(" {} ", self.app_time.format(&v)) + match (state.app_time_format, self.selected_content) { + // Show time + (Some(v), content) if content != Content::LocalTime => format!(" {} " // add some space around + , self.app_time.format(&v)), + // Hide time -> empty + _ => "".into(), } ).right_aligned()) .border_set(border::PLAIN) @@ -102,36 +104,39 @@ impl StatefulWidget for Footer { const SPACE: &str = " "; // 2 empty spaces let widths = [Constraint::Length(12), Constraint::Percentage(100)]; - let table = Table::new( - [ - // screens - Row::new(vec![ - Cell::from(Span::styled( - "screens", - Style::default().add_modifier(Modifier::BOLD), + let mut table_rows = vec![ + // screens + Row::new(vec![ + Cell::from(Span::styled( + "screens", + Style::default().add_modifier(Modifier::BOLD), + )), + Cell::from(Line::from(content_labels)), + ]), + // appearance + Row::new(vec![ + Cell::from(Span::styled( + "appearance", + Style::default().add_modifier(Modifier::BOLD), + )), + Cell::from(Line::from(vec![ + Span::from("[,]change style"), + Span::from(SPACE), + Span::from("[.]toggle deciseconds"), + Span::from(SPACE), + Span::from(format!( + "[:]toggle {} time", + match self.app_time { + AppTime::Local(_) => "local", + AppTime::Utc(_) => "utc", + } )), - Cell::from(Line::from(content_labels)), - ]), - // appearance - Row::new(vec![ - Cell::from(Span::styled( - "appearance", - Style::default().add_modifier(Modifier::BOLD), - )), - Cell::from(Line::from(vec![ - Span::from("[,]change style"), - Span::from(SPACE), - Span::from("[.]toggle deciseconds"), - Span::from(SPACE), - Span::from(format!( - "[:]toggle {} time", - match self.app_time { - AppTime::Local(_) => "local", - AppTime::Utc(_) => "utc", - } - )), - ])), - ]), + ])), + ]), + ]; + + if self.selected_content != Content::LocalTime { + table_rows.extend_from_slice(&[ // controls - 1. row Row::new(vec![ Cell::from(Span::styled( @@ -224,10 +229,10 @@ impl StatefulWidget for Footer { } })), ]), - ], - widths, - ) - .column_spacing(1); + ]) + } + + let table = Table::new(table_rows, widths).column_spacing(1); Widget::render(table, menu_area, buf); } diff --git a/src/widgets/local_time.rs b/src/widgets/local_time.rs new file mode 100644 index 0000000..633f1a6 --- /dev/null +++ b/src/widgets/local_time.rs @@ -0,0 +1,185 @@ +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{StatefulWidget, Widget}, +}; + +use crate::{ + common::{AppTime, AppTimeFormat, Style as DigitStyle}, + duration::DurationEx, + events::{TuiEvent, TuiEventHandler}, + utils::center, + widgets::clock_elements::{ + COLON_WIDTH, Colon, DIGIT_HEIGHT, DIGIT_SPACE_WIDTH, DIGIT_WIDTH, Digit, + }, +}; +use std::cmp::max; + +/// State for `LocalTimeWidget` +pub struct LocalTimeState { + time: AppTime, + format: AppTimeFormat, +} + +pub struct LocalTimeStateArgs { + pub app_time: AppTime, + pub app_time_format: AppTimeFormat, +} + +impl LocalTimeState { + pub fn new(args: LocalTimeStateArgs) -> Self { + let LocalTimeStateArgs { + app_time, + app_time_format, + } = args; + + Self { + time: app_time, + format: app_time_format, + } + } + + pub fn set_app_time(&mut self, app_time: AppTime) { + self.time = app_time; + } + + pub fn set_app_time_format(&mut self, format: AppTimeFormat) { + self.format = format; + } +} + +impl TuiEventHandler for LocalTimeState { + fn update(&mut self, event: TuiEvent) -> Option { + Some(event) + } +} + +#[derive(Debug)] +pub struct LocalTimeWidget { + pub style: DigitStyle, +} + +impl LocalTimeWidget { + fn get_horizontal_lengths(&self, format: &AppTimeFormat) -> Vec { + const PERIOD_WIDTH: u16 = 2; // PM or AM + + match format { + AppTimeFormat::HhMmSs => vec![ + DIGIT_WIDTH, // H + DIGIT_SPACE_WIDTH, // (space) + DIGIT_WIDTH, // h + COLON_WIDTH, // : + DIGIT_WIDTH, // M + DIGIT_SPACE_WIDTH, // (space) + DIGIT_WIDTH, // m + COLON_WIDTH, // : + DIGIT_WIDTH, // S + DIGIT_SPACE_WIDTH, // (space) + DIGIT_WIDTH, // s + ], + AppTimeFormat::HhMm => vec![ + DIGIT_WIDTH, // H + DIGIT_SPACE_WIDTH, // (space) + DIGIT_WIDTH, // h + COLON_WIDTH, // : + DIGIT_WIDTH, // M + DIGIT_SPACE_WIDTH, // (space) + DIGIT_WIDTH, // m + ], + AppTimeFormat::Hh12Mm => vec![ + DIGIT_SPACE_WIDTH + PERIOD_WIDTH, // (space) + (empty period) to center everything well horizontally + DIGIT_WIDTH, // H + DIGIT_SPACE_WIDTH, // (space) + DIGIT_WIDTH, // h + COLON_WIDTH, // : + DIGIT_WIDTH, // M + DIGIT_SPACE_WIDTH, // (space) + DIGIT_WIDTH, // m + DIGIT_SPACE_WIDTH, // (space) + PERIOD_WIDTH, // period + ], + } + } +} + +impl StatefulWidget for LocalTimeWidget { + type State = LocalTimeState; + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let current_value: DurationEx = state.time.as_duration_of_today().into(); + let hours = current_value.hours_mod(); + let hours12 = current_value.hours_mod_12(); + let minutes = current_value.minutes_mod(); + let seconds = current_value.seconds_mod(); + let symbol = self.style.get_digit_symbol(); + + let label = Line::raw("Local Time".to_uppercase()); + + let format = state.format; + let widths = self.get_horizontal_lengths(&format); + let mut widths = widths; + // Special case for `Hh12Mm` + // It might be `h:Mm` OR `Hh:Mm` depending on `hours12` + if state.format == AppTimeFormat::Hh12Mm && hours12 < 10 { + // single digit means, no (zero) width's for `H` and `space` + widths[1] = 0; // `H` + widths[2] = 0; // `space` + } + + let width = widths.iter().sum(); + let area = center( + area, + Constraint::Length(max(width, label.width() as u16)), + Constraint::Length(DIGIT_HEIGHT + 1 /* height of label */), + ); + + let [v1, v2] = Layout::vertical(Constraint::from_lengths([DIGIT_HEIGHT, 1])).areas(area); + + match state.format { + AppTimeFormat::HhMmSs => { + let [hh, _, h, c_hm, mm, _, m, c_ms, ss, _, s] = + Layout::horizontal(Constraint::from_lengths(widths)).areas(v1); + Digit::new(hours / 10, false, symbol).render(hh, buf); + Digit::new(hours % 10, false, symbol).render(h, buf); + Colon::new(symbol).render(c_hm, buf); + Digit::new(minutes / 10, false, symbol).render(mm, buf); + Digit::new(minutes % 10, false, symbol).render(m, buf); + Colon::new(symbol).render(c_ms, buf); + Digit::new(seconds / 10, false, symbol).render(ss, buf); + Digit::new(seconds % 10, false, symbol).render(s, buf); + } + AppTimeFormat::HhMm => { + let [hh, _, h, c_hm, mm, _, m] = + Layout::horizontal(Constraint::from_lengths(widths)).areas(v1); + Digit::new(hours / 10, false, symbol).render(hh, buf); + Digit::new(hours % 10, false, symbol).render(h, buf); + Colon::new(symbol).render(c_hm, buf); + Digit::new(minutes / 10, false, symbol).render(mm, buf); + Digit::new(minutes % 10, false, symbol).render(m, buf); + } + AppTimeFormat::Hh12Mm => { + let [_, hh, _, h, c_hm, mm, _, m, _, p] = + Layout::horizontal(Constraint::from_lengths(widths)).areas(v1); + // Hh + if hours12 >= 10 { + Digit::new(hours12 / 10, false, symbol).render(hh, buf); + Digit::new(hours12 % 10, false, symbol).render(h, buf); + } + // h + else { + Digit::new(hours12, false, symbol).render(h, buf); + } + Colon::new(symbol).render(c_hm, buf); + Digit::new(minutes / 10, false, symbol).render(mm, buf); + Digit::new(minutes % 10, false, symbol).render(m, buf); + Span::styled( + state.time.get_period().to_uppercase(), + Style::default().add_modifier(Modifier::BOLD), + ) + .render(p, buf); + } + } + label.centered().render(v2, buf); + } +}