diff --git a/src/clock.rs b/src/clock.rs index f8d1118..220682e 100644 --- a/src/clock.rs +++ b/src/clock.rs @@ -1,12 +1,12 @@ #![warn(clippy::pedantic, clippy::style, clippy::nursery)] #![allow(clippy::question_mark_used)] -use chrono::{DateTime, Datelike, Local, SubsecRound, Timelike}; +use chrono::{DateTime, Local, SubsecRound, TimeZone, Timelike}; use clap::Parser; use libpt::cli::args::HELP_TEMPLATE; use libpt::cli::clap::ArgGroup; use libpt::cli::{args::VerbosityLevel, clap}; -use libpt::log::{debug, trace}; +use libpt::log::{debug, info, trace}; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::event::{self, poll, Event, KeyCode, KeyModifiers}; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; @@ -45,6 +45,8 @@ impl Default for TimeBarLength { #[derive(Parser, Debug, Clone)] #[command(help_template = HELP_TEMPLATE, author, version)] #[clap(group( ArgGroup::new("timebarlen") .args(&["minute","day", "hour", "custom"]),))] +#[allow(clippy::struct_excessive_bools)] // the struct is for cli parsing and we already use an + // ArgGroup pub struct Clock { #[command(flatten)] pub verbose: VerbosityLevel, @@ -62,7 +64,57 @@ pub struct Clock { #[clap(short, long)] pub custom: Option, #[clap(skip)] - last_reset: Option>, + pub(crate) last_reset: Option>, +} + +#[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 { @@ -81,43 +133,45 @@ impl Clock { } } - fn timebar_ratio(&self) -> Option { + #[allow(clippy::cast_precision_loss)] // okay, good to know, but I accept the loss. It + // shouldn't come to more than 2^52 seconds anyway + pub(crate) fn timebar_ratio(&self, current_time: DateTime) -> Option { let len = self.timebar_len()?; - let since = (Local::now() + let since = current_time .signed_duration_since(self.last_reset.unwrap()) - .num_seconds() - + 1) as f64; - Some((since / len.as_secs() as f64).min(1.0).max(0.0)) + .num_seconds() as f64; + #[cfg(debug_assertions)] + if since < 1.0 { + trace!("ratio calculation since is now <1: {:#?}", since); + } + Some((since / len.as_secs() as f64).clamp(0.0, 1.0)) } - fn maybe_reset_since_zero(&mut self) { + pub(crate) fn maybe_reset_since_zero(&mut self) { if let Some(len) = self.timebar_len() { - trace!("Local Time: {}", Local::now()); - // BUG: these resets trigger multiple times + let since_last_reset = Local::now().signed_duration_since(self.last_reset.unwrap()); match len { TimeBarLength::Custom(_) => { - if Local::now() - .signed_duration_since(self.last_reset.unwrap()) - .num_seconds() - >= len.as_secs() + if since_last_reset.num_seconds() >= 1 + && since_last_reset.num_seconds() >= len.as_secs() { self.last_reset = Some(Local::now()); } } TimeBarLength::Minute => { - if Local::now().second() == 0 { + if since_last_reset.num_seconds() >= 1 && Local::now().second() == 0 { self.last_reset = Some(Local::now()); debug!("reset the time of the time bar (minute)"); } } TimeBarLength::Hour => { - if Local::now().minute() == 0 { + if since_last_reset.num_minutes() >= 1 && Local::now().minute() == 0 { self.last_reset = Some(Local::now()); debug!("reset the time of the time bar (hour)"); } } TimeBarLength::Day => { - if Local::now().hour() == 0 { + if since_last_reset.num_hours() >= 1 && Local::now().hour() == 0 { self.last_reset = Some(Local::now()); debug!("reset the time of the time bar (day)"); } @@ -159,7 +213,8 @@ impl Clock { } } - fn setup(&mut self) -> anyhow::Result<()> { + #[allow(clippy::unnecessary_wraps)] // we have that to be future proof + pub(crate) fn setup(&mut self) -> anyhow::Result<()> { self.setup_last_reset(); Ok(()) } @@ -170,6 +225,7 @@ impl Clock { ) -> anyhow::Result<()> { let tick_rate = Duration::from_millis(100); let mut last_tick = Instant::now(); + let mut uidata: UiData = UiData::default(); self.setup()?; loop { let raw_time = chrono::Local::now().round_subsecs(0); @@ -179,9 +235,31 @@ impl Clock { .split_whitespace() .map(str::to_string) .collect(); - let fdate: String = splits[0].clone(); - let ftime: String = splits[1].clone(); - self.ui(terminal, ftime, fdate)?; + + // We somehow fill timebar_ratio with a bad value here if we don't add 1 second. It's + // always the value that would be right for now-1s. The start of the minute is + // special, with this strategy it is 100%. #10 + // + // If we manually add a second, it works as expected, but it feels weird. We use the + // same time for all of the datapoints here, so it can't be because of time diff in + // calculation. I noticed that we don't start at 0% this way (with len=minute) + // . Normally, chrono does not include 60 seconds, only letting it range between 0 and + // 59. This makes sense but feels weird to the human understanding, of course there are + // seconds in a minute! If we do it this way, we don't quite start at 0%, but 100%, + // which feels correct. + // + // In short: if we add a second here, we get the correct percentages. 01:00 is 100%, + // 01:30 is 50%, 01:59 is 98%, 01:60 does not exist because that's how counting from + // 0 works. + + uidata.update( + splits[0].clone(), + splits[1].clone(), + self.timebar_ratio(raw_time + chrono::Duration::seconds(1)), + ); + if uidata.changed() { + self.ui(terminal, &uidata)?; + } let timeout = tick_rate.saturating_sub(last_tick.elapsed()); if poll(timeout)? { if let Event::Key(key) = event::read()? { @@ -206,10 +284,16 @@ impl Clock { fn ui( &self, terminal: &mut Terminal>, - ftime: String, - fdate: String, + data: &UiData, ) -> anyhow::Result<()> { + let clockw = tui_big_text::BigText::builder() + .style(Style::new().red()) + .lines(vec![data.ftime().into()]) + .alignment(Alignment::Center) + .build() + .expect("could not render time widget"); terminal.draw(|frame| { + debug!("rendering the ui"); let root = frame.size(); let space = Block::bordered() .padding(Padding::new( @@ -223,28 +307,15 @@ impl Clock { .title_alignment(Alignment::Center) .title_style(Style::new().bold()); let a = space.inner(root); - let parts = Self::partition(a); - let clockw = tui_big_text::BigText::builder() - .style(Style::new().red()) - .lines(vec![ftime.into()]) - .alignment(Alignment::Center) - .build() - .expect("could not render time widget"); - let datew = Paragraph::new(fdate) - .blue() - .alignment(Alignment::Left) - .block(Block::new().padding(Padding::new( - parts[1].left(), - parts[1].right() / 3, - 0, - 0, - ))); - frame.render_widget(space, root); - frame.render_widget(clockw, parts[0]); - frame.render_widget(datew, parts[1]); - if self.timebar_len().is_some() { - let timebarw = LineGauge::default() + let parts = Self::partition(a); + + // 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 tmp = LineGauge::default() .filled_style(Style::default().blue()) .unfilled_style(Style::default()) .block(Block::new().padding(Padding::new( @@ -253,17 +324,34 @@ impl Clock { 0, 0, ))) - .ratio(self.timebar_ratio().unwrap()); - debug!("time bar ration: {}", self.timebar_ratio().unwrap()); - frame.render_widget(timebarw, parts[2]); - } + .ratio(data.timebar_ratio().unwrap()); + Some(tmp) + } else { + None + }; + + // render the small date + let datew = Paragraph::new(data.fdate()) + .blue() + .alignment(Alignment::Left) + .block(Block::new().padding(Padding::new( + parts[1].left(), + parts[1].right() / 3, + 0, + 0, + ))); + frame.render_widget(&timebarw, parts[2]); + frame.render_widget(datew, parts[1]); + // render the clock + frame.render_widget(clockw, parts[0]); })?; + debug!("done rendering the ui"); Ok(()) } fn partition(r: Rect) -> Vec { let part = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Percentage(43), Constraint::Min(0)]) + .constraints([Constraint::Length(8), Constraint::Min(0)]) .split(r); let subparts = Layout::default() .direction(Direction::Horizontal) diff --git a/src/main.rs b/src/main.rs index 20e8a56..065a46b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,13 +22,16 @@ fn main() -> anyhow::Result<()> { .log_to_file(true) .log_dir("/tmp/crock/".into()) .set_level(clock.verbose.level()) - .display_time(false) + .display_time(true) .build()?; } else { // no logger } debug!("set up logger"); + #[cfg(debug_assertions)] + mock_tests(); + debug!("taking over terminal"); // setup terminal enable_raw_mode()?; @@ -53,3 +56,38 @@ fn main() -> anyhow::Result<()> { debug!("done"); result } + +#[cfg(debug_assertions)] +#[allow(clippy::cast_precision_loss)] +fn mock_tests() { + use chrono::{Local, Timelike}; + use libpt::log::info; + + use self::clock::UiData; + info!("doing the mock tests"); + { + let mut c = Clock::parse_from(["some exec", "-mvvv"]); + let now = Local::now(); + c.last_reset = Some(now.with_second(0).unwrap()); + + assert_eq!(c.timebar_ratio(now.with_second(30).unwrap()), Some(0.5)); + info!("30s=0.5"); + assert_eq!( + c.timebar_ratio(now.with_second(59).unwrap()), + Some(0.9833333333333333) + ); + info!("60s=1.0"); + assert_eq!(c.timebar_ratio(now.with_second(0).unwrap()), Some(0.0)); + info!("0s=0.0"); + } + { + let mut data = UiData::default(); + data.update("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)); + assert_eq!(data.timebar_ratio(), Some(0.2)); + data.update("date".to_owned(), "time".to_owned(), Some(0.3)); + assert_eq!(data.timebar_ratio(), Some(0.3)); + } + info!("finished the mock tests"); +}