diff --git a/Cargo.toml b/Cargo.toml index 639252d..91389ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "crock" -version = "0.1.5" +version = "0.2.2" edition = "2021" publish = true authors = ["Christoph J. Scherr "] @@ -12,10 +12,23 @@ repository = "https://git.cscherr.de/PlexSheep/crock" keywords = ["time", "clock", "tui"] categories = ["date-and-time"] +[features] +default = ["desktop", "sound"] +desktop = ["dep:notify-rust"] +sound = ["dep:rodio"] + [dependencies] anyhow = "1.0.86" chrono = "0.4.38" +human-panic = "2.0.0" +humantime = "2.1.0" libpt = { version = "0.6.0", features = ["cli"] } +notify-rust = { version = "4.11.0", default-features = false, features = [ + "d", +], optional = true } ratatui = "0.27.0" +rodio = { version = "0.19.0", optional = true, default-features = false, features = [ + "mp3", +] } tui-big-text = "0.4.5" diff --git a/src/clock.rs b/src/clock.rs index 162eb0f..c8cdf92 100644 --- a/src/clock.rs +++ b/src/clock.rs @@ -11,38 +11,16 @@ use ratatui::backend::CrosstermBackend; use ratatui::crossterm::event::{self, poll, Event, KeyCode, KeyModifiers}; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Style, Stylize}; -use ratatui::widgets::{Block, LineGauge, Padding, Paragraph}; +use ratatui::widgets::{Block, Padding, Paragraph}; use ratatui::Terminal; +use std::collections::HashMap; use std::io::{Cursor, Stdout, Write}; -use std::time::{Duration, Instant}; +use std::time::Instant; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TimeBarLength { - Minute, - Hour, - Custom(i128), - /// implementing a bar that would grow smaller would be weird, so it's a count up instead of - /// a countdown - Countup(i128), - Day, -} - -impl TimeBarLength { - pub(crate) const fn as_secs(self) -> i128 { - match self { - Self::Minute => 60, - Self::Day => 24 * 60 * 60, - Self::Hour => 60 * 60, - Self::Custom(secs) | Self::Countup(secs) => secs, - } - } -} - -impl Default for TimeBarLength { - fn default() -> Self { - Self::Minute - } -} +pub mod timebar; +pub mod ui; +use timebar::TimeBarLength; +use ui::Data; /// Make your terminal into a big clock #[derive(Parser, Debug, Clone)] @@ -78,7 +56,8 @@ pub struct Clock { #[clap(short = 'u', long, value_parser = humantime::parse_duration)] pub countdown: Option, /// Play a notification sound when the countdown is up - #[clap(short, long)] + #[cfg(feature = "sound")] + #[clap(short, long, default_value_t = true)] pub sound: bool, // internal variables @@ -88,56 +67,6 @@ pub struct Clock { pub(crate) did_notify: bool, } -#[derive(Debug, Clone, PartialEq, Default)] -pub struct UiData { - fdate: [String; 2], - ftime: [String; 2], - timebar_ratio: [Option; 2], - - data_idx: usize, -} - -impl UiData { - pub fn update(&mut self, fdate: String, ftime: String, timebar_ratio: Option) { - self.data_idx ^= 1; - self.fdate[self.data_idx] = fdate; - self.ftime[self.data_idx] = ftime; - self.timebar_ratio[self.data_idx] = timebar_ratio; - #[cfg(debug_assertions)] - if self.changed() { - trace!("update with change: {:#?}", self); - } - } - - /// did the data change with the last update? - #[must_use] - #[inline] - pub fn changed(&self) -> bool { - // the timebar ratio is discarded, so that we only render the ui when the time - // (second) changes - self.fdate[0] != self.fdate[1] || self.ftime[0] != self.ftime[1] - } - - #[must_use] - #[inline] - pub fn fdate(&self) -> &str { - &self.fdate[self.data_idx] - } - - #[must_use] - #[inline] - pub fn ftime(&self) -> &str { - &self.ftime[self.data_idx] - } - - #[must_use] - #[inline] - #[allow(clippy::missing_const_for_fn)] // no it's not const - pub fn timebar_ratio(&self) -> Option { - self.timebar_ratio[self.data_idx] - } -} - impl Clock { #[must_use] #[allow(clippy::missing_const_for_fn)] @@ -149,13 +78,11 @@ impl Clock { } else if self.hour { Some(TimeBarLength::Hour) } else if self.countdown.is_some() { - Some(TimeBarLength::Countup(i128::from( - self.countdown.unwrap().as_secs(), - ))) + Some(TimeBarLength::Countup( + self.countdown.unwrap().as_secs() as i64 + )) } else if self.custom.is_some() { - Some(TimeBarLength::Custom(i128::from( - self.custom.unwrap().as_secs(), - ))) + Some(TimeBarLength::Custom(self.custom.unwrap().as_secs() as i64)) } else { None } @@ -184,26 +111,26 @@ impl Clock { } TimeBarLength::Custom(_) => { if since_last_reset.num_seconds() >= 1 - && i128::from(since_last_reset.num_seconds()) >= len.as_secs() + && since_last_reset.num_seconds() >= len.as_secs() { - self.last_reset = Some(Local::now()); + self.last_reset = Some(Local::now().round_subsecs(0)); } } TimeBarLength::Minute => { if since_last_reset.num_seconds() >= 1 && Local::now().second() == 0 { - self.last_reset = Some(Local::now()); + self.last_reset = Some(Local::now().round_subsecs(0)); debug!("reset the time of the time bar (minute)"); } } TimeBarLength::Hour => { if since_last_reset.num_minutes() >= 1 && Local::now().minute() == 0 { - self.last_reset = Some(Local::now()); + self.last_reset = Some(Local::now().round_subsecs(0)); debug!("reset the time of the time bar (hour)"); } } TimeBarLength::Day => { if since_last_reset.num_hours() >= 1 && Local::now().hour() == 0 { - self.last_reset = Some(Local::now()); + self.last_reset = Some(Local::now().round_subsecs(0)); debug!("reset the time of the time bar (day)"); } } @@ -221,13 +148,17 @@ impl Clock { TimeBarLength::Minute => { self.last_reset = Some( Local::now() - .with_second(0) + .round_subsecs(0) + .with_second(1) .expect("tried to use a time that does not exist"), ); } TimeBarLength::Hour => { self.last_reset = Some( Local::now() + .round_subsecs(0) + .with_second(1) + .expect("tried to use a time that does not exist") .with_minute(0) .expect("tried to use a time that does not exist"), ); @@ -235,6 +166,11 @@ impl Clock { TimeBarLength::Day => { self.last_reset = Some( Local::now() + .round_subsecs(0) + .with_second(1) + .expect("tried to use a time that does not exist") + .with_minute(0) + .expect("tried to use a time that does not exist") .with_hour(0) .expect("tried to use a time that does not exist"), ); @@ -261,9 +197,9 @@ impl Clock { mut self, terminal: &mut Terminal>, ) -> anyhow::Result<()> { - let tick_rate = Duration::from_millis(100); + let tick_rate = std::time::Duration::from_millis(100); let mut last_tick = Instant::now(); - let mut uidata: UiData = UiData::default(); + let mut uidata: Data = Data::default(); self.setup()?; loop { let raw_time = chrono::Local::now().round_subsecs(0); @@ -290,10 +226,12 @@ impl Clock { // 01:30 is 50%, 01:59 is 98%, 01:60 does not exist because that's how counting from // 0 works. + let now = raw_time + chrono::Duration::seconds(1); uidata.update( + now, splits[0].clone(), splits[1].clone(), - self.timebar_ratio(raw_time + chrono::Duration::seconds(1)), + self.timebar_ratio(now), ); if uidata.changed() { self.ui(terminal, &uidata)?; @@ -322,7 +260,7 @@ impl Clock { fn ui( &mut self, terminal: &mut Terminal>, - data: &UiData, + data: &Data, ) -> anyhow::Result<()> { terminal.draw(|frame| { debug!("rendering the ui"); @@ -338,12 +276,12 @@ impl Clock { .title_bottom(env!("CARGO_PKG_VERSION")) .title_alignment(Alignment::Center) .title_style(Style::new().bold()); - let a = space.inner(root); + let inner_rect = space.inner(root); frame.render_widget(space, root); - let parts = Self::partition(a); + let parts = Self::partition(inner_rect); let mut clockw = tui_big_text::BigText::builder(); - if a.width > 80 { + if inner_rect.width > 80 { clockw.pixel_size(tui_big_text::PixelSize::Full); } else { clockw.pixel_size(tui_big_text::PixelSize::Quadrant); @@ -359,52 +297,24 @@ impl Clock { // render the timebar which counts up to the full minute and so on // // Will not be rendered if it is None - let timebarw: Option = if self.timebar_len().is_some() { - debug!("time bar ration: {:?}", data.timebar_ratio()); - let ratio = data.timebar_ratio().unwrap(); - - if !self.did_notify && (ratio - 1.0).abs() < 0.000_001 { - if let Some(TimeBarLength::Countup(_)) = self.timebar_len() { - let _ = self.notify().inspect_err(|e| { - error!("could not notify: {e}"); - debug!("complete error: {e:#?}"); - }); - self.did_notify = true; - } - } - - let timebarw = LineGauge::default() - .filled_style(if self.did_notify { - Style::default() - .slow_blink() - .bold() - .underlined() - .yellow() - .crossed_out() - } else { - Style::default().blue() - }) - .unfilled_style(Style::default()) - .block(Block::default().padding(Padding::right(if a.width > 80 { - (f32::from(parts[2].width) * 0.43) as u16 - } else { - (f32::from(parts[2].width) * 0.25) as u16 - }))) - .ratio(ratio); - Some(timebarw) - } else { - None - }; + let timebarw_padding = [ + (f32::from(parts["timebarw"].width) * 0.43) as u16, + (f32::from(parts["timebarw"].width) * 0.25) as u16, + ]; + let timebarw = ui::timebarw(self, data, &timebarw_padding, inner_rect); + let timebarw_label: Option = + ui::timebarw_label(self, data, &timebarw_padding, inner_rect); // render the small date let datew = Paragraph::new(data.fdate()) .blue() .block(Block::default().padding(Padding::right(2))) .alignment(Alignment::Right); - frame.render_widget(&timebarw, parts[2]); - frame.render_widget(datew, parts[1]); + frame.render_widget(&timebarw, parts["timebarw"]); + frame.render_widget(&timebarw_label, parts["timebarw_label"]); + frame.render_widget(datew, parts["datew"]); // render the clock - frame.render_widget(clockw, parts[0]); + frame.render_widget(clockw, parts["clockw"]); })?; debug!("done rendering the ui"); Ok(()) @@ -445,9 +355,6 @@ impl Clock { // NOTE: sadly, notify_rust does not (yet) support KDE plasma, because // they have a weird way of making sounds and notifications in general // work. At least we get a little notification. - // - // TODO: add something to make a sound without the notification system, - // as that is not reliable but the user might depend on it. // only play this when we don't use built in sound, this // isn't as consistent @@ -481,7 +388,7 @@ impl Clock { std::io::stdout().flush()?; Ok(()) } - fn partition(r: Rect) -> Vec { + fn partition(r: Rect) -> HashMap<&'static str, Rect> { let part = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -489,6 +396,8 @@ impl Clock { Constraint::Length(if r.width > 80 { 8 } else { 5 }), ]) .split(r); + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] let hlen_date: u16 = (f32::from(part[1].width) * 0.35) as u16; let subparts = Layout::default() .direction(Direction::Horizontal) @@ -498,6 +407,16 @@ impl Clock { ]) .split(part[0]); - vec![part[1], subparts[0], subparts[1]] + let timebarw_spaces = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)]) + .split(subparts[1]); + + HashMap::from([ + ("clockw", part[1]), + ("timebarw", timebarw_spaces[0]), + ("timebarw_label", timebarw_spaces[1]), + ("datew", subparts[0]), + ]) } } diff --git a/src/clock/timebar.rs b/src/clock/timebar.rs new file mode 100644 index 0000000..4be656e --- /dev/null +++ b/src/clock/timebar.rs @@ -0,0 +1,65 @@ +use std::fmt::Display; + +use chrono::Duration; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TimeBarLength { + Minute, + Hour, + Custom(i64), + /// implementing a bar that would grow smaller would be weird, so it's a count up instead of + /// a countdown + Countup(i64), + Day, +} + +impl TimeBarLength { + pub(crate) const fn as_secs(self) -> i64 { + match self { + Self::Minute => 60, + Self::Day => 24 * 60 * 60, + Self::Hour => 60 * 60, + Self::Custom(secs) | Self::Countup(secs) => secs, + } + } +} + +impl From for chrono::Duration { + fn from(value: TimeBarLength) -> Self { + Self::new(value.as_secs(), 0).expect("seconds out of bounds, cannot create duration") + } +} + +impl Default for TimeBarLength { + fn default() -> Self { + Self::Minute + } +} + +impl Display for TimeBarLength { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let buf = match self { + Self::Minute => humantime::Duration::from( + Duration::minutes(1) + .to_std() + .expect("could not convert chrono time to std time"), + ), + Self::Hour => humantime::Duration::from( + Duration::hours(1) + .to_std() + .expect("could not convert chrono time to std time"), + ), + Self::Day => humantime::Duration::from( + Duration::days(1) + .to_std() + .expect("could not convert chrono time to std time"), + ), + Self::Custom(secs) | Self::Countup(secs) => humantime::Duration::from( + Duration::seconds(*secs) + .to_std() + .expect("could not convert chrono time to std time"), + ), + }; + write!(f, "{buf}") + } +} diff --git a/src/clock/ui.rs b/src/clock/ui.rs new file mode 100644 index 0000000..f13b98d --- /dev/null +++ b/src/clock/ui.rs @@ -0,0 +1,183 @@ +use chrono::{DateTime, Local, SubsecRound, Timelike}; +use libpt::log::{debug, error, trace}; +use ratatui::layout::{Alignment, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::widgets::{Block, LineGauge, Padding, Paragraph}; + +use crate::clock::timebar::TimeBarLength; + +use super::Clock; + +// TODO: make this a ringbuffer with a custom struct inside? +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Data { + now: [DateTime; 2], + fdate: [String; 2], + ftime: [String; 2], + timebar_ratio: [Option; 2], + + idx: usize, +} + +impl Data { + pub fn update( + &mut self, + now: DateTime, + fdate: String, + ftime: String, + timebar_ratio: Option, + ) { + self.idx ^= 1; + self.now[self.idx] = now; + self.fdate[self.idx] = fdate; + self.ftime[self.idx] = ftime; + self.timebar_ratio[self.idx] = timebar_ratio; + #[cfg(debug_assertions)] + if self.changed() { + trace!("update with change: {:#?}", self); + } + } + + /// did the data change with the last update? + #[must_use] + #[inline] + pub fn changed(&self) -> bool { + // the timebar ratio is discarded, so that we only render the ui when the time + // (second) changes + self.fdate[0] != self.fdate[1] || self.ftime[0] != self.ftime[1] + } + + #[must_use] + #[inline] + pub fn fdate(&self) -> &str { + &self.fdate[self.idx] + } + + #[must_use] + #[inline] + pub fn ftime(&self) -> &str { + &self.ftime[self.idx] + } + + #[must_use] + #[inline] + pub fn now(&self) -> &DateTime { + &self.now[self.idx] + } + + #[must_use] + #[inline] + #[allow(clippy::missing_const_for_fn)] // no it's not const + pub fn timebar_ratio(&self) -> Option { + self.timebar_ratio[self.idx] + } +} + +pub fn timebarw<'a>( + clock: &mut Clock, + data: &Data, + timebarw_padding: &[u16], + inner_rect: Rect, +) -> Option> { + if clock.timebar_len().is_some() { + debug!("time bar ration: {:?}", data.timebar_ratio()); + let ratio = data.timebar_ratio().unwrap(); + + if !clock.did_notify && (ratio - 1.0).abs() < 0.000_001 { + if let Some(TimeBarLength::Countup(_)) = clock.timebar_len() { + let _ = clock.notify().inspect_err(|e| { + error!("could not notify: {e}"); + debug!("complete error: {e:#?}"); + }); + clock.did_notify = true; + } + } + + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + let timebarw = LineGauge::default() + .filled_style(if clock.did_notify { + Style::default() + .slow_blink() + .bold() + .underlined() + .yellow() + .crossed_out() + } else { + Style::default().blue() + }) + .unfilled_style(Style::default()) + .block( + Block::default().padding(Padding::right(if inner_rect.width > 80 { + timebarw_padding[0] + } else { + timebarw_padding[1] + })), + ) + .ratio(ratio); + Some(timebarw) + } else { + None + } +} + +pub fn timebarw_label<'a>( + clock: &Clock, + data: &Data, + timebarw_padding: &[u16], + inner_rect: Rect, +) -> Option> { + clock.timebar_len().map(|len| { + let last_reset = clock.last_reset.unwrap().round_subsecs(0); + let time_now = match clock.timebar_len().unwrap() { + TimeBarLength::Countup(secs) => { + if clock.did_notify { + humantime::Duration::from(chrono::Duration::seconds(secs).to_std().unwrap()) + } else { + humantime::Duration::from( + data.now() + .round_subsecs(0) + .signed_duration_since(last_reset) + .to_std() + .unwrap(), + ) + } + } + TimeBarLength::Hour => humantime::Duration::from( + data.now() + .signed_duration_since(last_reset) + .to_std() + .unwrap(), + ), + _ => humantime::Duration::from( + data.now() + .round_subsecs(0) + .signed_duration_since(last_reset) + .to_std() + .unwrap(), + ), + }; + let until = { + // we need to cut off the seconds if we're not in custom and countup mode, otherwise, + // the timestamp will not be correct. This fixes #17 + match len { + TimeBarLength::Custom(_) | TimeBarLength::Countup(_) => last_reset, + _ => last_reset.with_second(0).unwrap(), + } + } + // BUG: seconds are sometimes a little too much, for + // example with `-o` #17 + .checked_add_signed(len.into()) + .expect("could not calculate when the countdown finishes") + .format("%H:%M:%S"); + Paragraph::new(format!("{time_now} / {len} ({until})")) + .alignment(Alignment::Center) + .block( + Block::default().padding(Padding::right(if inner_rect.width > 80 { + timebarw_padding[0] + } else { + timebarw_padding[1] + })), + ) + }) +} diff --git a/src/main.rs b/src/main.rs index 065a46b..0186fa2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,13 @@ use self::clock::Clock; mod clock; fn main() -> anyhow::Result<()> { + human_panic::setup_panic!(human_panic::Metadata::new( + env!("CARGO_BIN_NAME"), + env!("CARGO_PKG_VERSION") + ) + .authors(env!("CARGO_PKG_AUTHORS")) + .homepage(env!("CARGO_PKG_HOMEPAGE"))); + // setup the cli let clock = Clock::parse(); if clock.verbose.level() >= Level::DEBUG { @@ -63,7 +70,7 @@ fn mock_tests() { use chrono::{Local, Timelike}; use libpt::log::info; - use self::clock::UiData; + use crate::clock::ui::Data; info!("doing the mock tests"); { let mut c = Clock::parse_from(["some exec", "-mvvv"]); @@ -81,12 +88,13 @@ fn mock_tests() { info!("0s=0.0"); } { - let mut data = UiData::default(); - data.update("date".to_owned(), "time".to_owned(), Some(0.1)); + let mut data = Data::default(); + let now = Local::now(); + data.update(now, "date".to_owned(), "time".to_owned(), Some(0.1)); assert_eq!(data.timebar_ratio(), Some(0.1)); - data.update("date".to_owned(), "time".to_owned(), Some(0.2)); + data.update(now, "date".to_owned(), "time".to_owned(), Some(0.2)); assert_eq!(data.timebar_ratio(), Some(0.2)); - data.update("date".to_owned(), "time".to_owned(), Some(0.3)); + data.update(now, "date".to_owned(), "time".to_owned(), Some(0.3)); assert_eq!(data.timebar_ratio(), Some(0.3)); } info!("finished the mock tests");