feat(event) Add widget (#117)

* skeleton
* make `Event` widget work
This commit is contained in:
Jens Krause 2025-10-05 21:05:14 +02:00 committed by GitHub
parent 6437795c9f
commit f79813ac6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 236 additions and 0 deletions

View File

@ -9,6 +9,7 @@ use crate::{
widgets::{
clock::{self, ClockState, ClockStateArgs},
countdown::{Countdown, CountdownState, CountdownStateArgs},
event::{EventState, EventStateArgs, EventWidget},
footer::{Footer, FooterState},
header::Header,
local_time::{LocalTimeState, LocalTimeStateArgs, LocalTimeWidget},
@ -29,6 +30,7 @@ use ratatui::{
};
use std::path::PathBuf;
use std::time::Duration;
use time::macros::format_description;
use tracing::{debug, error};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -49,6 +51,7 @@ pub struct App {
countdown: CountdownState,
timer: TimerState,
pomodoro: PomodoroState,
event: EventState,
local_time: LocalTimeState,
style: Style,
with_decis: bool,
@ -223,6 +226,17 @@ impl App {
app_time,
app_time_format,
}),
event: EventState::new(EventStateArgs {
app_time,
event_time: time::PrimitiveDateTime::parse(
"2030-10-03 15:00:00",
format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"),
)
.unwrap(),
event_title: "My event".to_owned(),
with_decis,
app_tx: app_tx.clone(),
}),
footer: FooterState::new(
show_menu,
if footer_toggle_app_time == Toggle::On {
@ -247,6 +261,9 @@ impl App {
KeyCode::Char('c') => app.content = Content::Countdown,
KeyCode::Char('t') => app.content = Content::Timer,
KeyCode::Char('p') => app.content = Content::Pomodoro,
// TODO(#102) Before we can use `e` here
// we do need to change keybindings for editing.
KeyCode::Char('z') => app.content = Content::Event,
KeyCode::Char('l') => app.content = Content::LocalTime,
// toogle app time format
KeyCode::Char(':') => {
@ -291,6 +308,7 @@ impl App {
app.timer.set_with_decis(app.with_decis);
app.countdown.set_with_decis(app.with_decis);
app.pomodoro.set_with_decis(app.with_decis);
app.event.set_with_decis(app.with_decis);
}
KeyCode::Up => app.footer.set_show_menu(true),
KeyCode::Down => app.footer.set_show_menu(false),
@ -303,6 +321,7 @@ impl App {
app.app_time = AppTime::new();
app.countdown.set_app_time(app.app_time);
app.local_time.set_app_time(app.app_time);
app.event.set_app_time(app.app_time);
}
// Pipe events into subviews and handle only 'unhandled' events afterwards
@ -310,6 +329,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::Event => app.event.update(event.clone()),
Content::LocalTime => app.local_time.update(event.clone()),
} {
match unhandled {
@ -401,6 +421,7 @@ impl App {
AppEditMode::None
}
}
Content::Event => AppEditMode::None,
Content::LocalTime => AppEditMode::None,
}
}
@ -410,6 +431,7 @@ 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(),
// `LocalTime` does not use a `Clock`
Content::LocalTime => false,
}
@ -420,6 +442,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::LocalTime => None,
}
}
@ -483,6 +506,11 @@ impl AppWidget {
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.pomodoro),
Content::Event => EventWidget {
style: state.style,
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.event),
Content::LocalTime => {
LocalTimeWidget { style: state.style }.render(area, buf, &mut state.local_time);
}

View File

@ -15,6 +15,8 @@ pub enum Content {
Timer,
#[value(name = "pomodoro", alias = "p")]
Pomodoro,
#[value(name = "event", alias = "e")]
Event,
#[value(name = "localtime", alias = "l")]
LocalTime,
}

View File

@ -5,6 +5,7 @@ use color_eyre::{
use std::cmp::min;
use std::fmt;
use std::time::Duration;
use time::OffsetDateTime;
use crate::common::AppTime;
@ -46,6 +47,29 @@ pub enum DirectedDuration {
Since(Duration),
}
impl From<DirectedDuration> for Duration {
fn from(directed: DirectedDuration) -> Self {
match directed {
DirectedDuration::Until(d) => d,
DirectedDuration::Since(d) => d,
}
}
}
impl DirectedDuration {
pub fn from_offset_date_times(value_a: OffsetDateTime, value_b: OffsetDateTime) -> Self {
let diff = value_a - value_b;
if diff.is_negative() {
Self::Since(Duration::from_millis(
diff.whole_milliseconds().unsigned_abs() as u64,
))
} else {
Self::Until(Duration::from_millis(diff.whole_milliseconds() as u64))
}
}
}
#[derive(Debug, Clone, Copy, PartialOrd)]
pub struct DurationEx {
inner: Duration,
@ -472,6 +496,31 @@ 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

View File

@ -6,6 +6,7 @@ pub mod clock_elements_test;
pub mod clock_test;
pub mod countdown;
pub mod edit_time;
pub mod event;
pub mod footer;
pub mod header;
pub mod local_time;

156
src/widgets/event.rs Normal file
View File

@ -0,0 +1,156 @@
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
text::Line,
widgets::{StatefulWidget, Widget},
};
use time::{OffsetDateTime, macros::format_description};
use crate::{
common::{AppTime, Style},
constants::TICK_VALUE_MS,
duration::DirectedDuration,
events::{AppEventTx, TuiEvent, TuiEventHandler},
utils::center,
widgets::clock::{self, ClockState, ClockStateArgs, ClockWidget},
};
use std::{cmp::max, time::Duration};
/// State for `EventWidget`
pub struct EventState {
title: String,
event_time: OffsetDateTime,
clock: ClockState<clock::Countdown>,
directed_duration: DirectedDuration,
}
pub struct EventStateArgs {
pub app_time: AppTime,
pub event_time: time::PrimitiveDateTime,
pub event_title: String,
pub with_decis: bool,
pub app_tx: AppEventTx,
}
impl EventState {
pub fn new(args: EventStateArgs) -> Self {
let EventStateArgs {
app_time,
event_time,
event_title,
with_decis,
app_tx,
} = args;
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,
}
}
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());
}
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(),
}
}
}
impl TuiEventHandler for EventState {
fn update(&mut self, event: TuiEvent) -> Option<TuiEvent> {
Some(event)
}
}
#[derive(Debug)]
pub struct EventWidget {
pub style: Style,
pub blink: bool,
}
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 label_event = Line::raw(state.title.to_uppercase());
let time_str = state
.event_time
.format(&format_description!(
"[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) {
"Done"
} else {
"Since"
}
}
DirectedDuration::Until(_) => "Until",
};
let label_time = Line::raw(format!(
"{} {}",
time_prefix.to_uppercase(),
time_str.to_uppercase()
));
let max_label_width = max(label_event.width(), label_time.width()) as u16;
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 */),
);
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(),
1, // event date
1, // event title
]))
.areas(area);
clock_widget.render(v1, buf, clock);
label_time.centered().render(v2, buf);
label_event.centered().render(v3, buf);
}
}