Introduce CalendarDuration (#120)

* trait ClockDuration, CalendarDuration, tests

* make clock rendering more generic

* remove `should_blink` from `RenderClockState`

* pass less down: `mode` -> `editable_time`

* simplify `event` duration states

* remove deprecated `DirectedDuration`

* fix comments
This commit is contained in:
Jens Krause 2025-10-09 19:51:34 +02:00 committed by GitHub
parent 99032834be
commit e2cd536079
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1315 additions and 1081 deletions

View File

@ -415,7 +415,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(),
Content::Event => self.event.get_clock().is_running(),
// Event clock runs forever
Content::Event => true,
// `LocalTime` does not use a `Clock`
Content::LocalTime => false,
}
@ -426,7 +427,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::Event => None,
Content::LocalTime => None,
}
}

View File

@ -7,8 +7,6 @@ use std::fmt;
use std::time::Duration;
use time::OffsetDateTime;
use crate::common::AppTime;
// unstable
// https://doc.rust-lang.org/src/core/time.rs.html#32
pub const SECS_PER_MINUTE: u64 = 60;
@ -38,36 +36,195 @@ pub const MAX_DURATION: Duration = ONE_YEAR
.saturating_mul(1000)
.saturating_sub(ONE_DECI_SECOND);
/// `Duration` with direction in time (past or future)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DirectedDuration {
/// Time `until` a future moment (positive `Duration`)
Until(Duration),
/// Time `since` a past moment (negative duration, but still represented as positive `Duration`)
Since(Duration),
/// Trait for duration types that can be displayed in clock widgets.
///
/// This trait abstracts over different duration calculation strategies:
/// - `DurationEx`: Uses fixed 365-day years (fast, simple)
/// - `CalendarDuration`: Uses actual calendar dates (accounts for leap years)
pub trait ClockDuration {
/// Total years
fn years(&self) -> u64;
/// Total days
fn days(&self) -> u64;
/// Days within the current year (0-364 or 0-365 for leap years)
fn days_mod(&self) -> u64;
/// Total hours
fn hours(&self) -> u64;
/// Hours within the current day (0-23)
fn hours_mod(&self) -> u64;
/// Hours as 12-hour clock (1-12)
fn hours_mod_12(&self) -> u64;
/// Total minutes
fn minutes(&self) -> u64;
/// Minutes within the current hour (0-59)
fn minutes_mod(&self) -> u64;
/// Total seconds
fn seconds(&self) -> u64;
/// Seconds within the current minute (0-59)
fn seconds_mod(&self) -> u64;
/// Deciseconds (tenths of a second, 0-9)
fn decis(&self) -> u64;
/// Total milliseconds
fn millis(&self) -> u128;
}
impl From<DirectedDuration> for Duration {
fn from(directed: DirectedDuration) -> Self {
match directed {
DirectedDuration::Until(d) => d,
DirectedDuration::Since(d) => d,
}
}
/// Calendar-aware duration that accounts for leap years.
///
/// Unlike `DurationEx` which uses fixed 365-day years, this calculates
/// years and days based on actual calendar dates, properly handling leap years.
///
/// All calculations are performed on-demand from the stored dates.
#[derive(Debug, Clone)]
pub struct CalendarDuration {
earlier: OffsetDateTime,
later: OffsetDateTime,
direction: CalendarDurationDirection,
}
impl DirectedDuration {
pub fn from_offset_date_times(value_a: OffsetDateTime, value_b: OffsetDateTime) -> Self {
let diff = value_a - value_b;
#[derive(PartialEq, Debug, Clone)]
pub enum CalendarDurationDirection {
Since,
Until,
}
if diff.is_negative() {
Self::Since(Duration::from_millis(
diff.whole_milliseconds().unsigned_abs() as u64,
))
impl CalendarDuration {
/// Create a new CalendarDuration by given two `OffsetDateTime`.
///
/// The order of arguments matters:
/// First: `start_time` - `OffsetDateTime` to start from
/// Second: `end_time` - `OffsetDateTime` for expected end
pub fn from_start_end_times(start_time: OffsetDateTime, end_time: OffsetDateTime) -> Self {
// To avoid negative values by calculating differences of `start` and `end` times,
// we might switch those values internally by storing it as `earlier` and `later` values
// It simplifies all calculations in `ClockDuration` trait later.
// And `direction` will still help to still get original `start` and `end` times later.
if start_time <= end_time {
Self {
earlier: start_time,
later: end_time,
direction: CalendarDurationDirection::Since,
}
} else {
Self::Until(Duration::from_millis(diff.whole_milliseconds() as u64))
Self {
earlier: end_time,
later: start_time,
direction: CalendarDurationDirection::Until,
}
}
}
pub fn direction(&self) -> &CalendarDurationDirection {
&self.direction
}
pub fn start_time(&self) -> &OffsetDateTime {
match self.direction {
CalendarDurationDirection::Since => &self.earlier,
CalendarDurationDirection::Until => &self.later,
}
}
pub fn end_time(&self) -> &OffsetDateTime {
match self.direction {
CalendarDurationDirection::Since => &self.later,
CalendarDurationDirection::Until => &self.earlier,
}
}
}
impl From<CalendarDuration> for Duration {
fn from(cal_duration: CalendarDuration) -> Self {
let diff = cal_duration.later - cal_duration.earlier;
Duration::from_millis(diff.whole_milliseconds().max(0) as u64)
}
}
impl ClockDuration for CalendarDuration {
fn years(&self) -> u64 {
let mut years = (self.later.year() - self.earlier.year()) as i64;
// Check if we've completed a full year by comparing month/day/time
let intermediate = self
.earlier
.replace_year(self.later.year())
.unwrap_or(self.earlier);
if intermediate > self.later {
years -= 1;
}
years.max(0) as u64
}
fn days_mod(&self) -> u64 {
let year_count = self.years();
// Calculate intermediate date after adding complete years
let target_year = self.earlier.year() + year_count as i32;
let intermediate = self
.earlier
.replace_year(target_year)
.unwrap_or(self.earlier);
let remaining = self.later - intermediate;
remaining.whole_days().max(0) as u64
}
fn days(&self) -> u64 {
(self.later - self.earlier).whole_days().max(0) as u64
}
fn hours_mod(&self) -> u64 {
let total_hours = (self.later - self.earlier).whole_hours();
(total_hours % 24).max(0) as u64
}
fn hours(&self) -> u64 {
(self.later - self.earlier).whole_hours().max(0) as u64
}
fn hours_mod_12(&self) -> u64 {
let hours = self.hours_mod();
(hours + 11) % 12 + 1
}
fn minutes_mod(&self) -> u64 {
let total_minutes = (self.later - self.earlier).whole_minutes();
(total_minutes % 60).max(0) as u64
}
fn minutes(&self) -> u64 {
(self.later - self.earlier).whole_minutes().max(0) as u64
}
fn seconds_mod(&self) -> u64 {
let total_seconds = (self.later - self.earlier).whole_seconds();
(total_seconds % 60).max(0) as u64
}
fn seconds(&self) -> u64 {
(self.later - self.earlier).whole_seconds().max(0) as u64
}
fn decis(&self) -> u64 {
let total_millis = (self.later - self.earlier).whole_milliseconds();
((total_millis % 1000) / 100).max(0) as u64
}
fn millis(&self) -> u128 {
(self.later - self.earlier).whole_milliseconds().max(0) as u128
}
}
#[derive(Debug, Clone, Copy, PartialOrd)]
@ -93,62 +250,60 @@ impl From<DurationEx> for Duration {
}
}
impl DurationEx {
pub fn years(&self) -> u64 {
impl ClockDuration for DurationEx {
fn years(&self) -> u64 {
self.days() / DAYS_PER_YEAR
}
pub fn days(&self) -> u64 {
fn days(&self) -> u64 {
self.hours() / HOURS_PER_DAY
}
/// Days in a year
pub fn days_mod(&self) -> u64 {
fn days_mod(&self) -> u64 {
self.days() % DAYS_PER_YEAR
}
pub fn hours(&self) -> u64 {
fn hours(&self) -> u64 {
self.seconds() / (SECS_PER_MINUTE * MINS_PER_HOUR)
}
/// Hours as 24-hour clock
pub fn hours_mod(&self) -> u64 {
fn hours_mod(&self) -> u64 {
self.hours() % HOURS_PER_DAY
}
/// Hours as 12-hour clock
pub fn hours_mod_12(&self) -> u64 {
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 {
fn minutes(&self) -> u64 {
self.seconds() / MINS_PER_HOUR
}
pub fn minutes_mod(&self) -> u64 {
fn minutes_mod(&self) -> u64 {
self.minutes() % SECS_PER_MINUTE
}
pub fn seconds(&self) -> u64 {
fn seconds(&self) -> u64 {
self.inner.as_secs()
}
pub fn seconds_mod(&self) -> u64 {
fn seconds_mod(&self) -> u64 {
self.seconds() % SECS_PER_MINUTE
}
// deciseconds
pub fn decis(&self) -> u64 {
fn decis(&self) -> u64 {
(self.inner.subsec_millis() / 100) as u64
}
// milliseconds
pub fn millis(&self) -> u128 {
fn millis(&self) -> u128 {
self.inner.as_millis()
}
}
impl DurationEx {
pub fn saturating_add(&self, ex: DurationEx) -> Self {
let inner = self.inner.saturating_add(ex.inner);
Self { inner }
@ -166,6 +321,7 @@ impl DurationEx {
impl fmt::Display for DurationEx {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use ClockDuration as _; // Import trait methods
if self.years() >= 1 {
write!(
f,
@ -233,85 +389,6 @@ fn parse_hours(h: &str) -> Result<u8, Report> {
Ok(hours)
}
/// Parses `DirectedDuration` from following formats:
/// - `yyyy-mm-dd hh:mm:ss`
/// - `yyyy-mm-dd hh:mm`
/// - `hh:mm:ss`
/// - `hh:mm`
/// - `mm`
///
/// Returns `DirectedDuration::Until` for future times, `DirectedDuration::Since` for past times
#[allow(dead_code)]
pub fn parse_duration_by_time(arg: &str) -> Result<DirectedDuration, Report> {
use time::{OffsetDateTime, PrimitiveDateTime, macros::format_description};
let now: OffsetDateTime = AppTime::new().into();
let target_time = if arg.contains('-') {
// First: `YYYY-MM-DD HH:MM:SS`
// Then: `YYYY-MM-DD HH:MM`
let format_with_seconds =
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
let format_without_seconds = format_description!("[year]-[month]-[day] [hour]:[minute]");
let pdt = PrimitiveDateTime::parse(arg, format_with_seconds)
.or_else(|_| PrimitiveDateTime::parse(arg, format_without_seconds))
.map_err(|e| {
eyre!("Invalid datetime '{}'. Use format 'yyyy-mm-dd hh:mm:ss' or 'yyyy-mm-dd hh:mm'. Error: {}", arg, e)
})?;
pdt.assume_offset(now.offset())
} else {
// Parse time parts: interpret as HH:MM:SS, HH:MM, or SS
let parts: Vec<&str> = arg.split(':').collect();
let (hour, minute, second) = match parts.as_slice() {
[mm] => {
// Single part: treat as minutes in current hour
let m = parse_minutes(mm)?;
(now.hour(), m, 0)
}
[hh, mm] => {
// Two parts: treat as HH:MM (time of day)
let h = parse_hours(hh)?;
let m = parse_minutes(mm)?;
(h, m, 0)
}
[hh, mm, ss] => {
// Three parts: HH:MM:SS
let h = parse_hours(hh)?;
let m = parse_minutes(mm)?;
let s = parse_seconds(ss)?;
(h, m, s)
}
_ => {
return Err(eyre!(
"Invalid time format. Use 'hh:mm:ss', 'hh:mm', or 'mm'"
));
}
};
now.replace_time(
time::Time::from_hms(hour, minute, second).map_err(|_| eyre!("Invalid time"))?,
)
};
let mut duration_secs = (target_time - now).whole_seconds();
// `Since` for past times
if duration_secs < 0 {
duration_secs *= -1;
Ok(DirectedDuration::Since(Duration::from_secs(
duration_secs as u64,
)))
} else
// `Until` for future times,
{
Ok(DirectedDuration::Until(Duration::from_secs(
duration_secs as u64,
)))
}
}
/// Parses `Duration` from `hh:mm:ss`, `mm:ss` or `ss`
pub fn parse_duration(arg: &str) -> Result<Duration, Report> {
let parts: Vec<&str> = arg.split(':').collect();
@ -396,6 +473,7 @@ pub fn parse_long_duration(arg: &str) -> Result<Duration, Report> {
#[cfg(test)]
mod tests {
use super::ClockDuration;
use super::*;
use std::time::Duration;
@ -497,31 +575,6 @@ 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
@ -543,42 +596,6 @@ mod tests {
assert!(parse_duration("01:02:03:04").is_err()); // too many parts
}
#[test]
fn test_parse_duration_by_time() {
// YYYY-MM-DD HH:MM:SS - future
assert!(matches!(
parse_duration_by_time("2050-06-15 14:30:45"),
Ok(DirectedDuration::Until(_))
));
// YYYY-MM-DD HH:MM - future
assert!(matches!(
parse_duration_by_time("2050-06-15 14:30"),
Ok(DirectedDuration::Until(_))
));
// HH:MM:SS - past
assert!(matches!(
parse_duration_by_time("2000-01-01 23:59:59"),
Ok(DirectedDuration::Since(_))
));
// HH:MM - Until or Since depending on current time
assert!(parse_duration_by_time("18:00").is_ok());
// MM - Until or Since depending on current time
assert!(parse_duration_by_time("45").is_ok());
// errors
assert!(parse_duration_by_time("60").is_err()); // invalid minutes
assert!(parse_duration_by_time("24:00").is_err()); // invalid hours
assert!(parse_duration_by_time("24:00:00").is_err()); // invalid hours
assert!(parse_duration_by_time("2030-13-01 12:00:00").is_err()); // invalid month
assert!(parse_duration_by_time("2030-06-32 12:00:00").is_err()); // invalid day
assert!(parse_duration_by_time("abc").is_err()); // invalid input
assert!(parse_duration_by_time("01:02:03:04").is_err()); // too many parts
}
#[test]
fn test_parse_long_duration() {
// `Yy`
@ -677,4 +694,155 @@ mod tests {
assert!(parse_long_duration("1y 2d 3d 4:00").is_err()); // too many parts (4 parts)
assert!(parse_long_duration("1y 2d 3h 4m 5s").is_err()); // too many parts (5 parts)
}
#[test]
fn test_calendar_duration_leap_year() {
use time::macros::datetime;
// 2024 is a leap year (366 days)
let start = datetime!(2024-01-01 00:00:00 UTC);
let end = datetime!(2025-01-01 00:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 1, "Should be exactly 1 year");
assert_eq!(cal_dur.days_mod(), 0, "Should be 0 remaining days");
assert_eq!(cal_dur.days(), 366, "2024 has 366 days (leap year)");
}
#[test]
fn test_calendar_duration_non_leap_year() {
use time::macros::datetime;
// 2023 is not a leap year (365 days)
let start = datetime!(2023-01-01 00:00:00 UTC);
let end = datetime!(2024-01-01 00:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 1, "Should be exactly 1 year");
assert_eq!(cal_dur.days_mod(), 0, "Should be 0 remaining days");
assert_eq!(cal_dur.days(), 365, "2023 has 365 days (non-leap year)");
}
#[test]
fn test_calendar_duration_partial_year_with_leap_day() {
use time::macros::datetime;
// Span including Feb 29, 2024
let start = datetime!(2024-02-01 00:00:00 UTC);
let end = datetime!(2024-03-15 00:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 0, "Should be 0 years");
// Feb 2024 has 29 days, so: 29 days (rest of Feb) + 15 days (March) = 44 days
assert_eq!(
cal_dur.days(),
43,
"Should be 43 days (29 in Feb + 14 partial March)"
);
}
#[test]
fn test_calendar_duration_partial_year_without_leap_day() {
use time::macros::datetime;
// Same dates but in 2023 (non-leap year)
let start = datetime!(2023-02-01 00:00:00 UTC);
let end = datetime!(2023-03-15 00:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 0, "Should be 0 years");
// Feb 2023 has 28 days, so: 28 days (rest of Feb) + 15 days (March) = 43 days
assert_eq!(
cal_dur.days(),
42,
"Should be 42 days (28 in Feb + 14 partial March)"
);
}
#[test]
fn test_calendar_duration_multiple_years_spanning_leap_years() {
use time::macros::datetime;
// From 2023 (non-leap) through 2024 (leap) to 2025
let start = datetime!(2023-03-01 10:00:00 UTC);
let end = datetime!(2025-03-01 10:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 2, "Should be exactly 2 years");
assert_eq!(cal_dur.days_mod(), 0, "Should be 0 remaining days");
// Total days: 365 (2023 partial + 2024 partial) + 366 (full 2024 year conceptually included)
// Actually: From 2023-03-01 to 2025-03-01 = 365 + 366 = 731 days
assert_eq!(cal_dur.days(), 731, "Should be 731 total days");
}
#[test]
fn test_calendar_duration_year_boundary() {
use time::macros::datetime;
// Test incomplete year - just before year boundary
let start = datetime!(2024-01-01 00:00:00 UTC);
let end = datetime!(2024-12-31 23:59:59 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 0, "Should be 0 years (not complete)");
assert_eq!(cal_dur.days(), 365, "Should be 365 days");
}
#[test]
fn test_calendar_duration_hours_minutes_seconds() {
use time::macros::datetime;
let start = datetime!(2024-01-01 10:30:45 UTC);
let end = datetime!(2024-01-02 14:25:50 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(cal_dur.years(), 0);
assert_eq!(cal_dur.days(), 1);
assert_eq!(cal_dur.hours_mod(), 3, "Should be 3 hours past midnight");
assert_eq!(cal_dur.minutes_mod(), 55, "Should be 55 minutes");
assert_eq!(cal_dur.seconds_mod(), 5, "Should be 5 seconds");
}
#[test]
fn test_calendar_duration_reversed_dates() {
use time::macros::datetime;
// CalendarDuration::between should handle reversed order
let later = datetime!(2025-01-01 00:00:00 UTC);
let earlier = datetime!(2024-01-01 00:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(later, earlier);
assert_eq!(cal_dur.years(), 1, "Should still calculate 1 year");
assert_eq!(cal_dur.days(), 366, "Should still be 366 days");
}
#[test]
fn test_calendar_duration_same_date() {
use time::macros::datetime;
let date = datetime!(2024-06-15 12:00:00 UTC);
let cal_dur = CalendarDuration::from_start_end_times(date, date);
assert_eq!(cal_dur.years(), 0);
assert_eq!(cal_dur.days(), 0);
assert_eq!(cal_dur.hours(), 0);
assert_eq!(cal_dur.minutes(), 0);
assert_eq!(cal_dur.seconds(), 0);
}
#[test]
fn test_calendar_duration_deciseconds() {
use time::macros::datetime;
let start = datetime!(2024-01-01 00:00:00.000 UTC);
let end = datetime!(2024-01-01 00:00:00.750 UTC);
let cal_dur = CalendarDuration::from_start_end_times(start, end);
assert_eq!(
cal_dur.decis(),
7,
"Should be 7 deciseconds (750ms = 7.5 decis, truncated to 7)"
);
assert_eq!(cal_dur.millis(), 750, "Should be 750 milliseconds");
}
}

View File

@ -14,8 +14,8 @@ use ratatui::{
use crate::{
common::{ClockTypeId, Style as DigitStyle},
duration::{
DurationEx, MAX_DURATION, ONE_DAY, ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND,
ONE_YEAR,
ClockDuration, DurationEx, MAX_DURATION, ONE_DAY, ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE,
ONE_SECOND, ONE_YEAR,
},
events::{AppEvent, AppEventTx},
utils::center_horizontal,
@ -90,7 +90,7 @@ pub enum Format {
YyyDddHhMmSs,
}
pub fn format_by_duration(d: &DurationEx) -> Format {
pub fn format_by_duration<D: ClockDuration>(d: &D) -> Format {
if d.years() >= 100 && d.days_mod() >= 100 {
Format::YyyDddHhMmSs
} else if d.years() >= 100 && d.days_mod() >= 10 {
@ -437,7 +437,7 @@ impl<T> ClockState<T> {
}
fn update_format(&mut self) {
let d = self.get_current_value();
let d: &DurationEx = self.get_current_value();
self.format = format_by_duration(d);
}
@ -642,7 +642,29 @@ where
}
}
fn get_horizontal_lengths(&self, format: &Format, with_decis: bool) -> Vec<u16> {
pub fn get_width(&self, format: &Format, with_decis: bool) -> u16 {
clock_horizontal_lengths(format, with_decis).iter().sum()
}
pub fn get_height(&self) -> u16 {
DIGIT_HEIGHT
}
/// Checks whether to blink the clock while rendering.
/// Its logic is based on a given `count` value.
fn should_blink(&self, count_value: &Option<u64>) -> bool {
// Example:
// if `RANGE_OF_DONE_COUNT` is 4
// then for ranges `0..4`, `8..12` etc. it will return `true`
count_value
.map(|b| (b % (RANGE_OF_DONE_COUNT * 2)) < RANGE_OF_DONE_COUNT)
.unwrap_or(false)
}
}
// Helper to get horizontal lengths of a clock
// depending on given `Format` and `with_decis` params
pub fn clock_horizontal_lengths(format: &Format, with_decis: bool) -> Vec<u16> {
let add_decis = |mut lengths: Vec<u16>, with_decis: bool| -> Vec<u16> {
if with_decis {
lengths.extend_from_slice(&[
@ -868,55 +890,38 @@ where
with_decis,
),
}
}
pub fn get_width(&self, format: &Format, with_decis: bool) -> u16 {
self.get_horizontal_lengths(format, with_decis).iter().sum()
}
pub fn get_height(&self) -> u16 {
DIGIT_HEIGHT
}
/// Checks whether to blink the clock while rendering.
/// Its logic is based on a given `count` value.
fn should_blink(&self, count_value: &Option<u64>) -> bool {
// Example:
// if `RANGE_OF_DONE_COUNT` is 4
// then for ranges `0..4`, `8..12` etc. it will return `true`
count_value
.map(|b| (b % (RANGE_OF_DONE_COUNT * 2)) < RANGE_OF_DONE_COUNT)
.unwrap_or(false)
}
}
impl<T> StatefulWidget for ClockWidget<T>
where
T: std::fmt::Debug,
{
type State = ClockState<T>;
// State to render a clock
pub struct RenderClockState<'a, D: ClockDuration> {
pub format: Format,
pub editable_time: Option<Time>,
pub with_decis: bool,
pub symbol: &'a str,
pub widths: Vec<u16>,
pub duration: D,
}
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let with_decis = state.with_decis;
let format = state.format;
// to simulate a blink effect, just use an "empty" symbol (string)
// to "empty" all digits and to have an "empty" render area
let symbol = if self.blink && self.should_blink(&state.done_count) {
" "
} else {
self.style.get_digit_symbol()
};
let widths = self.get_horizontal_lengths(&format, with_decis);
let area = center_horizontal(
area,
Constraint::Length(self.get_width(&format, with_decis)),
);
let edit_years = matches!(state.mode, Mode::Editable(Time::Years, _));
let edit_days = matches!(state.mode, Mode::Editable(Time::Days, _));
let edit_hours = matches!(state.mode, Mode::Editable(Time::Hours, _));
let edit_minutes = matches!(state.mode, Mode::Editable(Time::Minutes, _));
let edit_secs = matches!(state.mode, Mode::Editable(Time::Seconds, _));
let edit_decis = matches!(state.mode, Mode::Editable(Time::Decis, _));
// Helper to render a clock
pub fn render_clock<D: ClockDuration>(area: Rect, buf: &mut Buffer, state: RenderClockState<D>) {
let RenderClockState {
format,
with_decis,
symbol,
widths,
editable_time,
duration,
} = state;
let width = widths.iter().sum();
let area = center_horizontal(area, Constraint::Length(width));
let edit_years = matches!(editable_time, Some(Time::Years));
let edit_days = matches!(editable_time, Some(Time::Days));
let edit_hours = matches!(editable_time, Some(Time::Hours));
let edit_minutes = matches!(editable_time, Some(Time::Minutes));
let edit_secs = matches!(editable_time, Some(Time::Seconds));
let edit_decis = matches!(editable_time, Some(Time::Decis));
let render_three_digits = |d1, d2, d3, editable, area, buf: &mut Buffer| {
let [a1, a2, a3] = Layout::horizontal(Constraint::from_lengths([
@ -950,9 +955,9 @@ where
let render_yyy = |area, buf| {
render_three_digits(
(state.current_value.years() / 100) % 10,
(state.current_value.years() / 10) % 10,
state.current_value.years() % 10,
(duration.years() / 100) % 10,
(duration.years() / 10) % 10,
duration.years() % 10,
edit_years,
area,
buf,
@ -961,8 +966,8 @@ where
let render_yy = |area, buf| {
render_two_digits(
(state.current_value.years() / 10) % 10,
state.current_value.years() % 10,
(duration.years() / 10) % 10,
duration.years() % 10,
edit_years,
area,
buf,
@ -970,14 +975,14 @@ where
};
let render_y = |area, buf| {
Digit::new(state.current_value.years() % 10, edit_years, symbol).render(area, buf);
Digit::new(duration.years() % 10, edit_years, symbol).render(area, buf);
};
let render_ddd = |area, buf| {
render_three_digits(
(state.current_value.days_mod() / 100) % 10,
(state.current_value.days_mod() / 10) % 10,
state.current_value.days_mod() % 10,
(duration.days_mod() / 100) % 10,
(duration.days_mod() / 10) % 10,
duration.days_mod() % 10,
edit_days,
area,
buf,
@ -986,8 +991,8 @@ where
let render_dd = |area, buf| {
render_two_digits(
(state.current_value.days_mod() / 10) % 10,
state.current_value.days_mod() % 10,
(duration.days_mod() / 10) % 10,
duration.days_mod() % 10,
edit_days,
area,
buf,
@ -995,13 +1000,13 @@ where
};
let render_d = |area, buf| {
Digit::new(state.current_value.days_mod() % 10, edit_days, symbol).render(area, buf);
Digit::new(duration.days_mod() % 10, edit_days, symbol).render(area, buf);
};
let render_hh = |area, buf| {
render_two_digits(
state.current_value.hours_mod() / 10,
state.current_value.hours_mod() % 10,
duration.hours_mod() / 10,
duration.hours_mod() % 10,
edit_hours,
area,
buf,
@ -1009,13 +1014,13 @@ where
};
let render_h = |area, buf| {
Digit::new(state.current_value.hours_mod() % 10, edit_hours, symbol).render(area, buf);
Digit::new(duration.hours_mod() % 10, edit_hours, symbol).render(area, buf);
};
let render_mm = |area, buf| {
render_two_digits(
state.current_value.minutes_mod() / 10,
state.current_value.minutes_mod() % 10,
duration.minutes_mod() / 10,
duration.minutes_mod() % 10,
edit_minutes,
area,
buf,
@ -1023,14 +1028,13 @@ where
};
let render_m = |area, buf| {
Digit::new(state.current_value.minutes_mod() % 10, edit_minutes, symbol)
.render(area, buf);
Digit::new(duration.minutes_mod() % 10, edit_minutes, symbol).render(area, buf);
};
let render_ss = |area, buf| {
render_two_digits(
state.current_value.seconds_mod() / 10,
state.current_value.seconds_mod() % 10,
duration.seconds_mod() / 10,
duration.seconds_mod() % 10,
edit_secs,
area,
buf,
@ -1038,11 +1042,11 @@ where
};
let render_s = |area, buf| {
Digit::new(state.current_value.seconds_mod() % 10, edit_secs, symbol).render(area, buf);
Digit::new(duration.seconds_mod() % 10, edit_secs, symbol).render(area, buf);
};
let render_ds = |area, buf| {
Digit::new(state.current_value.decis(), edit_decis, symbol).render(area, buf);
Digit::new(duration.decis(), edit_decis, symbol).render(area, buf);
};
let render_label = |l: &str, area, buf: &mut Buffer| {
@ -1436,8 +1440,7 @@ where
render_ds(ds, buf);
}
Format::MmSs => {
let [m_m, c_ms, s_s] =
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
let [m_m, c_ms, s_s] = Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
render_mm(m_m, buf);
render_colon(c_ms, buf);
render_ss(s_s, buf);
@ -1452,15 +1455,13 @@ where
render_ds(ds, buf);
}
Format::MSs => {
let [m, c_ms, s_s] =
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
let [m, c_ms, s_s] = Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
render_m(m, buf);
render_colon(c_ms, buf);
render_ss(s_s, buf);
}
Format::Ss if state.with_decis => {
let [s_s, dot, ds] =
Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
let [s_s, dot, ds] = Layout::horizontal(Constraint::from_lengths(widths)).areas(area);
render_ss(s_s, buf);
render_dot(dot, buf);
render_ds(ds, buf);
@ -1480,5 +1481,38 @@ where
render_s(s, buf);
}
}
}
impl<T> StatefulWidget for ClockWidget<T>
where
T: std::fmt::Debug,
{
type State = ClockState<T>;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let with_decis = state.with_decis;
let format = state.format;
let widths = clock_horizontal_lengths(&format, with_decis);
// to simulate a blink effect, just use an "empty" symbol (string)
// to "empty" all digits and to have an "empty" render area
let symbol = if self.blink && self.should_blink(&state.done_count) {
" "
} else {
self.style.get_digit_symbol()
};
let render_state = RenderClockState {
with_decis,
duration: state.current_value,
editable_time: match state.get_mode() {
Mode::Editable(time, _) => Some(*time),
_ => None,
},
format,
symbol,
widths,
};
render_clock(area, buf, render_state);
}
}

View File

@ -1,7 +1,8 @@
use crate::{
common::ClockTypeId,
duration::{
MAX_DURATION, ONE_DAY, ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND, ONE_YEAR,
DurationEx, MAX_DURATION, ONE_DAY, ONE_DECI_SECOND, ONE_HOUR, ONE_MINUTE, ONE_SECOND,
ONE_YEAR,
},
widgets::clock::*,
};
@ -76,102 +77,129 @@ fn test_get_format_hours() {
#[test]
fn test_format_by_duration_boundaries() {
// S
assert_eq!(format_by_duration(&(ONE_SECOND * 9).into()), Format::S);
assert_eq!(
format_by_duration::<DurationEx>(&(ONE_SECOND * 9).into()),
Format::S
);
// Ss
assert_eq!(format_by_duration(&(10 * ONE_SECOND).into()), Format::Ss);
assert_eq!(
format_by_duration::<DurationEx>(&(10 * ONE_SECOND).into()),
Format::Ss
);
// Ss
assert_eq!(format_by_duration(&(59 * ONE_SECOND).into()), Format::Ss);
assert_eq!(
format_by_duration::<DurationEx>(&(59 * ONE_SECOND).into()),
Format::Ss
);
// MSs
assert_eq!(format_by_duration(&ONE_MINUTE.into()), Format::MSs);
assert_eq!(
format_by_duration::<DurationEx>(&ONE_MINUTE.into()),
Format::MSs
);
// HhMmSs
assert_eq!(
format_by_duration(&(ONE_DAY.saturating_sub(ONE_SECOND)).into()),
format_by_duration::<DurationEx>(&(ONE_DAY.saturating_sub(ONE_SECOND)).into()),
Format::HhMmSs
);
// DHhMmSs
assert_eq!(format_by_duration(&ONE_DAY.into()), Format::DHhMmSs);
assert_eq!(
format_by_duration::<DurationEx>(&ONE_DAY.into()),
Format::DHhMmSs
);
// DHhMmSs
assert_eq!(
format_by_duration(&((10 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
format_by_duration::<DurationEx>(&((10 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
Format::DHhMmSs
);
// DdHhMmSs
assert_eq!(format_by_duration(&(10 * ONE_DAY).into()), Format::DdHhMmSs);
assert_eq!(
format_by_duration::<DurationEx>(&(10 * ONE_DAY).into()),
Format::DdHhMmSs
);
// DdHhMmSs
assert_eq!(
format_by_duration(&((100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
format_by_duration::<DurationEx>(&((100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
Format::DdHhMmSs
);
// DddHhMmSs
assert_eq!(
format_by_duration(&(100 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(100 * ONE_DAY).into()),
Format::DddHhMmSs
);
// DddHhMmSs
assert_eq!(
format_by_duration(&(ONE_YEAR.saturating_sub(ONE_SECOND).into())),
format_by_duration::<DurationEx>(&(ONE_YEAR.saturating_sub(ONE_SECOND).into())),
Format::DddHhMmSs
);
// YDHhMmSs
assert_eq!(format_by_duration(&ONE_YEAR.into()), Format::YDHhMmSs);
assert_eq!(
format_by_duration::<DurationEx>(&ONE_YEAR.into()),
Format::YDHhMmSs
);
// YDdHhMmSs
assert_eq!(
format_by_duration(&(ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
format_by_duration::<DurationEx>(
&(ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()
),
Format::YDdHhMmSs
);
// YDddHhMmSs
assert_eq!(
format_by_duration(&(ONE_YEAR + 100 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(ONE_YEAR + 100 * ONE_DAY).into()),
Format::YDddHhMmSs
);
// YDddHhMmSs
assert_eq!(
format_by_duration(&((10 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
format_by_duration::<DurationEx>(&((10 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
Format::YDddHhMmSs
);
// YyDHhMmSs
assert_eq!(
format_by_duration(&(10 * ONE_YEAR).into()),
format_by_duration::<DurationEx>(&(10 * ONE_YEAR).into()),
Format::YyDHhMmSs
);
// YyDdHhMmSs
assert_eq!(
format_by_duration(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
Format::YyDdHhMmSs
);
// YyDdHhMmSs
assert_eq!(
format_by_duration(&(10 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
format_by_duration::<DurationEx>(
&(10 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()
),
Format::YyDdHhMmSs
);
// YyDddHhMmSs
assert_eq!(
format_by_duration(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
Format::YyDddHhMmSs
);
// YyDddHhMmSs
assert_eq!(
format_by_duration(&((100 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
format_by_duration::<DurationEx>(&((100 * ONE_YEAR).saturating_sub(ONE_SECOND)).into()),
Format::YyDddHhMmSs
);
// YyyDHhMmSs
assert_eq!(
format_by_duration(&(100 * ONE_YEAR).into()),
format_by_duration::<DurationEx>(&(100 * ONE_YEAR).into()),
Format::YyyDHhMmSs
);
// YyyDdHhMmSs
assert_eq!(
format_by_duration(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
Format::YyyDdHhMmSs
);
// YyyDdHhMmSs
assert_eq!(
format_by_duration(&(100 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()),
format_by_duration::<DurationEx>(
&(100 * ONE_YEAR + (100 * ONE_DAY).saturating_sub(ONE_SECOND)).into()
),
Format::YyyDdHhMmSs
);
// YyyDddHhMmSs
assert_eq!(
format_by_duration(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
Format::YyyDddHhMmSs
);
}
@ -179,12 +207,18 @@ fn test_format_by_duration_boundaries() {
#[test]
fn test_format_by_duration_days() {
// DHhMmSs
assert_eq!(format_by_duration(&ONE_DAY.into()), Format::DHhMmSs);
assert_eq!(
format_by_duration::<DurationEx>(&ONE_DAY.into()),
Format::DHhMmSs
);
// DdHhMmSs
assert_eq!(format_by_duration(&(10 * ONE_DAY).into()), Format::DdHhMmSs);
assert_eq!(
format_by_duration::<DurationEx>(&(10 * ONE_DAY).into()),
Format::DdHhMmSs
);
// DddHhMmSs
assert_eq!(
format_by_duration(&(101 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(101 * ONE_DAY).into()),
Format::DddHhMmSs
);
}
@ -192,59 +226,62 @@ fn test_format_by_duration_days() {
#[test]
fn test_format_by_duration_years() {
// YDHhMmSs (1 year, 0 days)
assert_eq!(format_by_duration(&ONE_YEAR.into()), Format::YDHhMmSs);
assert_eq!(
format_by_duration::<DurationEx>(&ONE_YEAR.into()),
Format::YDHhMmSs
);
// YDHhMmSs (1 year, 1 day)
assert_eq!(
format_by_duration(&(ONE_YEAR + ONE_DAY).into()),
format_by_duration::<DurationEx>(&(ONE_YEAR + ONE_DAY).into()),
Format::YDHhMmSs
);
// YDdHhMmSs (1 year, 10 days)
assert_eq!(
format_by_duration(&(ONE_YEAR + 10 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(ONE_YEAR + 10 * ONE_DAY).into()),
Format::YDdHhMmSs
);
// YDddHhMmSs (1 year, 100 days)
assert_eq!(
format_by_duration(&(ONE_YEAR + 100 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(ONE_YEAR + 100 * ONE_DAY).into()),
Format::YDddHhMmSs
);
// YyDHhMmSs (10 years)
assert_eq!(
format_by_duration(&(10 * ONE_YEAR).into()),
format_by_duration::<DurationEx>(&(10 * ONE_YEAR).into()),
Format::YyDHhMmSs
);
// YyDdHhMmSs (10 years, 10 days)
assert_eq!(
format_by_duration(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(10 * ONE_YEAR + 10 * ONE_DAY).into()),
Format::YyDdHhMmSs
);
// YyDddHhMmSs (10 years, 100 days)
assert_eq!(
format_by_duration(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(10 * ONE_YEAR + 100 * ONE_DAY).into()),
Format::YyDddHhMmSs
);
// YyyDHhMmSs (100 years)
assert_eq!(
format_by_duration(&(100 * ONE_YEAR).into()),
format_by_duration::<DurationEx>(&(100 * ONE_YEAR).into()),
Format::YyyDHhMmSs
);
// YyyDdHhMmSs (100 years, 10 days)
assert_eq!(
format_by_duration(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(100 * ONE_YEAR + 10 * ONE_DAY).into()),
Format::YyyDdHhMmSs
);
// YyyDddHhMmSs (100 years, 100 days)
assert_eq!(
format_by_duration(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
format_by_duration::<DurationEx>(&(100 * ONE_YEAR + 100 * ONE_DAY).into()),
Format::YyyDddHhMmSs
);
}

View File

@ -8,11 +8,10 @@ use time::{OffsetDateTime, macros::format_description};
use crate::{
common::{AppTime, Style},
constants::TICK_VALUE_MS,
duration::DirectedDuration,
duration::{CalendarDuration, CalendarDurationDirection},
events::{AppEventTx, TuiEvent, TuiEventHandler},
utils::center,
widgets::clock::{self, ClockState, ClockStateArgs, ClockWidget},
widgets::{clock, clock_elements::DIGIT_HEIGHT},
};
use std::{cmp::max, time::Duration};
@ -20,8 +19,8 @@ use std::{cmp::max, time::Duration};
pub struct EventState {
title: String,
event_time: OffsetDateTime,
clock: ClockState<clock::Countdown>,
directed_duration: DirectedDuration,
app_time: OffsetDateTime,
with_decis: bool,
}
pub struct EventStateArgs {
@ -42,52 +41,27 @@ impl EventState {
app_tx,
} = args;
// TODO: Handle app Events
let _ = app_tx;
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::<clock::Countdown>::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,
app_time: app_datetime,
with_decis,
}
}
pub fn get_clock(&self) -> &ClockState<clock::Countdown> {
&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());
self.app_time = app_datetime;
}
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(),
}
self.with_decis = with_decis;
}
}
@ -106,8 +80,13 @@ pub struct EventWidget {
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 with_decis = state.with_decis;
let clock_duration =
CalendarDuration::from_start_end_times(state.event_time, state.app_time);
let clock_format = clock::format_by_duration(&clock_duration);
let clock_widths = clock::clock_horizontal_lengths(&clock_format, with_decis);
let clock_width = clock_widths.iter().sum();
let label_event = Line::raw(state.title.to_uppercase());
let time_str = state
.event_time
@ -115,17 +94,19 @@ impl StatefulWidget for EventWidget {
"[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) {
let time_prefix = if clock_duration.direction() == &CalendarDurationDirection::Since {
let duration: Duration = clock_duration.clone().into();
// Show `done` for a short of time (1 sec)
if duration < Duration::from_secs(1) {
"Done"
} else {
"Since"
}
}
DirectedDuration::Until(_) => "Until",
} else {
"Until"
};
let label_time = Line::raw(format!(
"{} {}",
time_prefix.to_uppercase(),
@ -135,21 +116,34 @@ impl StatefulWidget for EventWidget {
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 */),
Constraint::Length(max(clock_width, max_label_width)),
Constraint::Length(DIGIT_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(),
DIGIT_HEIGHT,
1, // event date
1, // event title
]))
.areas(area);
clock_widget.render(v1, buf, clock);
// TODO: Add logic to handle blink in `DONE` mode, similar to `ClockWidget<T>::should_blink`
let symbol = if self.blink {
" "
} else {
self.style.get_digit_symbol()
};
let render_clock_state = clock::RenderClockState {
with_decis,
duration: clock_duration,
editable_time: None,
format: clock_format,
symbol,
widths: clock_widths,
};
clock::render_clock(v1, buf, render_clock_state);
label_time.centered().render(v2, buf);
label_event.centered().render(v3, buf);
}

View File

@ -8,7 +8,7 @@ use ratatui::{
use crate::{
common::{AppTime, AppTimeFormat, Style as DigitStyle},
duration::DurationEx,
duration::{ClockDuration, DurationEx},
events::{TuiEvent, TuiEventHandler},
utils::center,
widgets::clock_elements::{