diff --git a/Cargo.lock b/Cargo.lock index 0b5d39a..d07bf9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -296,6 +296,15 @@ dependencies = [ "syn", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "diff" version = "0.1.13" @@ -649,6 +658,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.32.2" @@ -723,6 +747,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -1050,6 +1080,39 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "timr-tui" version = "0.9.0" @@ -1063,6 +1126,7 @@ dependencies = [ "serde", "serde_json", "strum", + "time", "tokio", "tokio-stream", "tokio-util", diff --git a/Cargo.toml b/Cargo.toml index 5ac1402..7ea1bf3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,3 +26,4 @@ tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } directories = "5.0.1" clap = { version = "4.5.23", features = ["derive"] } +time = { version = "0.3.37", features = ["formatting", "local-offset"] } diff --git a/demo/local-time.gif b/demo/local-time.gif new file mode 100644 index 0000000..b1da6d2 Binary files /dev/null and b/demo/local-time.gif differ diff --git a/demo/local-time.tape b/demo/local-time.tape new file mode 100644 index 0000000..7bafe99 --- /dev/null +++ b/demo/local-time.tape @@ -0,0 +1,22 @@ +Output demo/local-time.gif + +# https://github.com/charmbracelet/vhs/blob/main/THEMES.md +Set Theme "AtomOneLight" + +Set FontSize 14 +Set Width 800 +Set Height 400 +Set Padding 0 +Set Margin 1 + +# --- START --- +Set LoopOffset 4 +Hide +Type "cargo run -- -m c" +Enter +Sleep 0.2 +Show +Sleep 1 +# --- toggle local time --- +Type@1.5s ":::" +Sleep 1.5 diff --git a/justfile b/justfile index 9361b0f..15f7a1d 100644 --- a/justfile +++ b/justfile @@ -63,3 +63,8 @@ alias dm := demo-menu demo-menu: vhs demo/menu.tape + +alias dlt := demo-local-time + +demo-local-time: + vhs demo/local-time.tape diff --git a/src/app.rs b/src/app.rs index b45467a..ec9beef 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,6 @@ use crate::{ args::Args, - common::{Content, Style}, + common::{AppTime, AppTimeFormat, Content, Style}, constants::TICK_VALUE_MS, events::{Event, EventHandler, Events}, storage::AppStorage, @@ -8,7 +8,7 @@ use crate::{ widgets::{ clock::{self, Clock, ClockArgs}, countdown::{Countdown, CountdownWidget}, - footer::Footer, + footer::{Footer, FooterState}, header::Header, pomodoro::{Mode as PomodoroMode, Pomodoro, PomodoroArgs, PomodoroWidget}, timer::{Timer, TimerWidget}, @@ -22,6 +22,7 @@ use ratatui::{ widgets::{StatefulWidget, Widget}, }; use std::time::Duration; +use time::OffsetDateTime; use tracing::debug; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -34,18 +35,20 @@ enum Mode { pub struct App { content: Content, mode: Mode, - show_menu: bool, + app_time: AppTime, countdown: Countdown, timer: Timer, pomodoro: Pomodoro, style: Style, with_decis: bool, + footer_state: FooterState, } pub struct AppArgs { pub style: Style, pub with_decis: bool, pub show_menu: bool, + pub app_time_format: AppTimeFormat, pub content: Content, pub pomodoro_mode: PomodoroMode, pub initial_value_work: Duration, @@ -64,6 +67,7 @@ impl From<(Args, AppStorage)> for AppArgs { AppArgs { with_decis: args.decis || stg.with_decis, show_menu: args.menu || stg.show_menu, + app_time_format: stg.app_time_format, content: args.mode.unwrap_or(stg.content), style: args.style.unwrap_or(stg.style), pomodoro_mode: stg.pomodoro_mode, @@ -81,11 +85,19 @@ impl From<(Args, AppStorage)> for AppArgs { } } +fn get_app_time() -> AppTime { + match OffsetDateTime::now_local() { + Ok(t) => AppTime::Local(t), + Err(_) => AppTime::Utc(OffsetDateTime::now_utc()), + } +} + impl App { pub fn new(args: AppArgs) -> Self { let AppArgs { style, show_menu, + app_time_format, initial_value_work, initial_value_pause, initial_value_countdown, @@ -100,7 +112,7 @@ impl App { Self { mode: Mode::Running, content, - show_menu, + app_time: get_app_time(), style, with_decis, countdown: Countdown::new(Clock::::new(ClockArgs { @@ -126,12 +138,17 @@ impl App { style, with_decis, }), + footer_state: FooterState::new(show_menu, app_time_format), } } pub async fn run(mut self, mut terminal: Terminal, mut events: Events) -> Result { while self.is_running() { if let Some(event) = events.next().await { + if matches!(event, Event::Tick) { + self.app_time = get_app_time(); + } + // Pipe events into subviews and handle only 'unhandled' events afterwards if let Some(unhandled) = match self.content { Content::Countdown => self.countdown.update(event.clone()), @@ -186,7 +203,12 @@ impl App { KeyCode::Char('c') => self.content = Content::Countdown, KeyCode::Char('t') => self.content = Content::Timer, KeyCode::Char('p') => self.content = Content::Pomodoro, - KeyCode::Char('m') => self.show_menu = !self.show_menu, + // toogle app time format + KeyCode::Char(':') => self.footer_state.toggle_app_time_format(), + // toogle menu + KeyCode::Char('m') => self + .footer_state + .set_show_menu(!self.footer_state.get_show_menu()), KeyCode::Char(',') => { self.style = self.style.next(); // update clocks @@ -201,8 +223,8 @@ impl App { self.countdown.set_with_decis(self.with_decis); self.pomodoro.set_with_decis(self.with_decis); } - KeyCode::Up => self.show_menu = true, - KeyCode::Down => self.show_menu = false, + KeyCode::Up => self.footer_state.set_show_menu(true), + KeyCode::Down => self.footer_state.set_show_menu(false), _ => {} }; } @@ -217,7 +239,8 @@ impl App { pub fn to_storage(&self) -> AppStorage { AppStorage { content: self.content, - show_menu: self.show_menu, + show_menu: self.footer_state.get_show_menu(), + app_time_format: *self.footer_state.app_time_format(), style: self.style, with_decis: self.with_decis, pomodoro_mode: self.pomodoro.get_mode().clone(), @@ -256,7 +279,11 @@ impl StatefulWidget for AppWidget { let [v0, v1, v2] = Layout::vertical([ Constraint::Length(1), Constraint::Percentage(100), - Constraint::Length(if state.show_menu { 4 } else { 1 }), + Constraint::Length(if state.footer_state.get_show_menu() { + 4 + } else { + 1 + }), ]) .areas(area); @@ -268,12 +295,12 @@ impl StatefulWidget for AppWidget { // content self.render_content(v1, buf, state); // footer - Footer { - show_menu: state.show_menu, + let footer = Footer { running_clock: state.clock_is_running(), selected_content: state.content, edit_mode: state.is_edit_mode(), - } - .render(v2, buf); + app_time: state.app_time, + }; + StatefulWidget::render(footer, v2, buf, &mut state.footer_state); } } diff --git a/src/common.rs b/src/common.rs index 8217d5a..c85fe45 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,6 +1,8 @@ use clap::ValueEnum; use ratatui::symbols::shade; use serde::{Deserialize, Serialize}; +use time::format_description; +use time::OffsetDateTime; #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Default, Serialize, Deserialize, @@ -62,3 +64,66 @@ impl Style { } } } + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub enum AppTimeFormat { + /// `hh:mm:ss` + #[default] + HhMmSs, + /// `hh:mm` + HhMm, + /// `hh:mm AM` (or PM) + Hh12Mm, + /// `` (empty) + Hidden, +} + +impl AppTimeFormat { + pub fn next(&self) -> Self { + match self { + AppTimeFormat::HhMmSs => AppTimeFormat::HhMm, + AppTimeFormat::HhMm => AppTimeFormat::Hh12Mm, + AppTimeFormat::Hh12Mm => AppTimeFormat::Hidden, + AppTimeFormat::Hidden => AppTimeFormat::HhMmSs, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum AppTime { + Local(OffsetDateTime), + Utc(OffsetDateTime), +} + +impl From for OffsetDateTime { + fn from(app_time: AppTime) -> Self { + match app_time { + AppTime::Local(t) => t, + AppTime::Utc(t) => t, + } + } +} + +impl AppTime { + pub fn format(&self, app_format: &AppTimeFormat) -> String { + let parse_str = match app_format { + AppTimeFormat::HhMmSs => Some("[hour]:[minute]:[second]"), + AppTimeFormat::HhMm => Some("[hour]:[minute]"), + AppTimeFormat::Hh12Mm => Some("[hour]:[minute] [period]"), + AppTimeFormat::Hidden => None, + }; + + if let Some(str) = parse_str { + format_description::parse(str) + .map_err(|_| "parse error") + .and_then(|fd| { + OffsetDateTime::from(*self) + .format(&fd) + .map_err(|_| "format error") + }) + .unwrap_or_else(|e| e.to_string()) + } else { + "".to_owned() + } + } +} diff --git a/src/storage.rs b/src/storage.rs index a30383f..a805cfa 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,5 +1,5 @@ use crate::{ - common::{Content, Style}, + common::{AppTimeFormat, Content, Style}, widgets::pomodoro::Mode as PomodoroMode, }; use color_eyre::eyre::Result; @@ -12,6 +12,7 @@ use std::time::Duration; pub struct AppStorage { pub content: Content, pub show_menu: bool, + pub app_time_format: AppTimeFormat, pub style: Style, pub with_decis: bool, pub pomodoro_mode: PomodoroMode, @@ -36,6 +37,7 @@ impl Default for AppStorage { AppStorage { content: Content::default(), show_menu: true, + app_time_format: AppTimeFormat::default(), style: Style::default(), with_decis: false, pomodoro_mode: PomodoroMode::Work, diff --git a/src/widgets/footer.rs b/src/widgets/footer.rs index c623dc0..8a22f66 100644 --- a/src/widgets/footer.rs +++ b/src/widgets/footer.rs @@ -1,25 +1,57 @@ use std::collections::BTreeMap; -use crate::common::Content; +use crate::common::{AppTime, AppTimeFormat, Content}; use ratatui::{ buffer::Buffer, layout::{Constraint, Layout, Rect}, style::{Modifier, Style}, symbols::{border, scrollbar}, text::{Line, Span}, - widgets::{Block, Borders, Cell, Row, Table, Widget}, + widgets::{Block, Borders, Cell, Row, StatefulWidget, Table, Widget}, }; #[derive(Debug, Clone)] +pub struct FooterState { + show_menu: bool, + app_time_format: AppTimeFormat, +} + +impl FooterState { + pub const fn new(show_menu: bool, app_time_format: AppTimeFormat) -> Self { + Self { + show_menu, + app_time_format, + } + } + + pub fn set_show_menu(&mut self, value: bool) { + self.show_menu = value; + } + + pub const fn get_show_menu(&self) -> bool { + self.show_menu + } + + pub const fn app_time_format(&self) -> &AppTimeFormat { + &self.app_time_format + } + + pub fn toggle_app_time_format(&mut self) { + self.app_time_format = self.app_time_format.next(); + } +} + +#[derive(Debug)] pub struct Footer { - pub show_menu: bool, pub running_clock: bool, pub selected_content: Content, pub edit_mode: bool, + pub app_time: AppTime, } -impl Widget for Footer { - fn render(self, area: Rect, buf: &mut Buffer) { +impl StatefulWidget for Footer { + type State = FooterState; + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let content_labels: BTreeMap = BTreeMap::from([ (Content::Countdown, "[c]ountdown"), (Content::Timer, "[t]imer"), @@ -31,15 +63,25 @@ impl Widget for Footer { let [border_area, menu_area] = Layout::vertical([Constraint::Length(1), Constraint::Percentage(100)]).areas(area); + Block::new() .borders(Borders::TOP) .title( - format! {"[m]enu {:} ", if self.show_menu {scrollbar::VERTICAL.end} else {scrollbar::VERTICAL.begin}}, + format! {"[m]enu {:} ", if state.show_menu {scrollbar::VERTICAL.end} else {scrollbar::VERTICAL.begin}}, ) + .title( + Line::from( + match state.app_time_format { + // `Hidden` -> no (empty) title + AppTimeFormat::Hidden => "".into(), + // others -> add some space around + _ => format!(" {} ", self.app_time.format(&state.app_time_format)) + } + ).right_aligned()) .border_set(border::PLAIN) .render(border_area, buf); // show menu - if self.show_menu { + if state.show_menu { let content_labels: Vec = content_labels .iter() .enumerate() @@ -60,7 +102,7 @@ impl Widget for Footer { const SPACE: &str = " "; // 2 empty spaces let widths = [Constraint::Length(12), Constraint::Percentage(100)]; - Table::new( + let table = Table::new( [ // content Row::new(vec![ @@ -80,6 +122,14 @@ impl Widget for Footer { 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", + } + )), ])), ]), // edit @@ -128,8 +178,9 @@ impl Widget for Footer { ], widths, ) - .column_spacing(1) - .render(menu_area, buf); + .column_spacing(1); + + Widget::render(table, menu_area, buf); } } }