timr-tui/src/app.rs
2025-08-30 21:48:56 +02:00

512 lines
19 KiB
Rust

use crate::{
args::Args,
common::{AppEditMode, AppTime, AppTimeFormat, ClockTypeId, Content, Style, Toggle},
constants::TICK_VALUE_MS,
events::{self, TuiEventHandler},
storage::AppStorage,
terminal::Terminal,
widgets::{
clock::{self, ClockState, ClockStateArgs},
countdown::{Countdown, CountdownState, CountdownStateArgs},
footer::{Footer, FooterState},
header::Header,
local_time::{LocalTimeState, LocalTimeStateArgs, LocalTimeWidget},
pomodoro::{Mode as PomodoroMode, PomodoroState, PomodoroStateArgs, PomodoroWidget},
timer::{Timer, TimerState},
},
};
#[cfg(feature = "sound")]
use crate::sound::Sound;
use color_eyre::Result;
use ratatui::{
buffer::Buffer,
crossterm::event::{KeyCode, KeyEvent},
layout::{Constraint, Layout, Rect},
widgets::{StatefulWidget, Widget},
};
use std::path::PathBuf;
use std::time::Duration;
use time::OffsetDateTime;
use tracing::{debug, error};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Running,
Quit,
}
pub struct App {
content: Content,
mode: Mode,
notification: Toggle,
blink: Toggle,
#[allow(dead_code)] // w/ `--features sound` available only
sound_path: Option<PathBuf>,
app_time: AppTime,
app_time_format: AppTimeFormat,
countdown: CountdownState,
timer: TimerState,
pomodoro: PomodoroState,
local_time: LocalTimeState,
style: Style,
with_decis: bool,
footer: FooterState,
}
pub struct AppArgs {
pub style: Style,
pub with_decis: bool,
pub notification: Toggle,
pub blink: Toggle,
pub show_menu: bool,
pub app_time_format: AppTimeFormat,
pub content: Content,
pub pomodoro_mode: PomodoroMode,
pub pomodoro_round: u64,
pub initial_value_work: Duration,
pub current_value_work: Duration,
pub initial_value_pause: Duration,
pub current_value_pause: Duration,
pub initial_value_countdown: Duration,
pub current_value_countdown: Duration,
pub elapsed_value_countdown: Duration,
pub current_value_timer: Duration,
pub app_tx: events::AppEventTx,
pub sound_path: Option<PathBuf>,
pub footer_toggle_app_time: Toggle,
}
pub struct FromAppArgs {
pub args: Args,
pub stg: AppStorage,
pub app_tx: events::AppEventTx,
}
/// Creates an `App` by merging `Args` and `AppStorage` (`Args` wins)
/// and adding `AppEventTx`
impl From<FromAppArgs> for App {
fn from(args: FromAppArgs) -> Self {
let FromAppArgs { args, stg, app_tx } = args;
App::new(AppArgs {
with_decis: args.decis || stg.with_decis,
show_menu: args.menu || stg.show_menu,
notification: args.notification.unwrap_or(stg.notification),
blink: args.blink.unwrap_or(stg.blink),
app_time_format: stg.app_time_format,
// Check args to set a possible mode to start with.
content: match args.mode {
Some(mode) => mode,
// check other args (especially durations)
None => {
if args.work.is_some() || args.pause.is_some() {
Content::Pomodoro
} else if args.countdown.is_some() {
Content::Countdown
}
// in other case just use latest stored state
else {
stg.content
}
}
},
style: args.style.unwrap_or(stg.style),
pomodoro_mode: stg.pomodoro_mode,
pomodoro_round: stg.pomodoro_count,
initial_value_work: args.work.unwrap_or(stg.inital_value_work),
// invalidate `current_value_work` if an initial value is set via args
current_value_work: args.work.unwrap_or(stg.current_value_work),
initial_value_pause: args.pause.unwrap_or(stg.inital_value_pause),
// invalidate `current_value_pause` if an initial value is set via args
current_value_pause: args.pause.unwrap_or(stg.current_value_pause),
initial_value_countdown: args.countdown.unwrap_or(stg.inital_value_countdown),
// invalidate `current_value_countdown` if an initial value is set via args
current_value_countdown: args.countdown.unwrap_or(stg.current_value_countdown),
elapsed_value_countdown: match args.countdown {
// reset value if countdown is set by arguments
Some(_) => Duration::ZERO,
None => stg.elapsed_value_countdown,
},
current_value_timer: stg.current_value_timer,
app_tx,
#[cfg(feature = "sound")]
sound_path: args.sound,
#[cfg(not(feature = "sound"))]
sound_path: None,
footer_toggle_app_time: stg.footer_app_time,
})
}
}
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,
current_value_work,
current_value_pause,
current_value_countdown,
elapsed_value_countdown,
current_value_timer,
content,
with_decis,
pomodoro_mode,
pomodoro_round,
notification,
blink,
sound_path,
app_tx,
footer_toggle_app_time,
} = args;
let app_time = get_app_time();
Self {
mode: Mode::Running,
notification,
blink,
sound_path,
content,
app_time,
app_time_format,
style,
with_decis,
countdown: CountdownState::new(CountdownStateArgs {
initial_value: initial_value_countdown,
current_value: current_value_countdown,
elapsed_value: elapsed_value_countdown,
app_time,
with_decis,
app_tx: app_tx.clone(),
}),
timer: TimerState::new(
ClockState::<clock::Timer>::new(ClockStateArgs {
initial_value: Duration::ZERO,
current_value: current_value_timer,
tick_value: Duration::from_millis(TICK_VALUE_MS),
with_decis,
app_tx: Some(app_tx.clone()),
})
.with_name("Timer".to_owned()),
),
pomodoro: PomodoroState::new(PomodoroStateArgs {
mode: pomodoro_mode,
initial_value_work,
current_value_work,
initial_value_pause,
current_value_pause,
with_decis,
round: pomodoro_round,
app_tx: app_tx.clone(),
}),
local_time: LocalTimeState::new(LocalTimeStateArgs {
app_time,
app_time_format,
}),
footer: FooterState::new(
show_menu,
if footer_toggle_app_time == Toggle::On {
Some(app_time_format)
} else {
None
},
),
}
}
pub async fn run(
mut self,
terminal: &mut Terminal,
mut events: events::Events,
) -> Result<Self> {
// Closure to handle `KeyEvent`'s
let handle_key_event = |app: &mut Self, key: KeyEvent| {
debug!("Received key {:?}", key.code);
match key.code {
KeyCode::Char('q') => app.mode = Mode::Quit,
KeyCode::Char('c') => app.content = Content::Countdown,
KeyCode::Char('t') => app.content = Content::Timer,
KeyCode::Char('p') => app.content = Content::Pomodoro,
KeyCode::Char('l') => app.content = Content::LocalTime,
// toogle app time format
KeyCode::Char(':') => {
if app.content == Content::LocalTime {
// For LocalTime content: just cycle through formats
app.app_time_format = app.app_time_format.next();
app.local_time.set_app_time_format(app.app_time_format);
// Only update footer if it's currently showing time
if app.footer.app_time_format().is_some() {
app.footer.set_app_time_format(Some(app.app_time_format));
}
} else {
// For other content: allow footer to toggle between formats and None
let new_format = match app.footer.app_time_format() {
// footer is hidden -> show first format
None => Some(AppTimeFormat::first()),
Some(v) => {
if v != &AppTimeFormat::last() {
Some(v.next())
} else {
// reached last format -> hide footer time
None
}
}
};
if let Some(format) = new_format {
app.app_time_format = format;
app.local_time.set_app_time_format(format);
}
app.footer.set_app_time_format(new_format);
}
}
// toogle menu
KeyCode::Char('m') => app.footer.set_show_menu(!app.footer.get_show_menu()),
KeyCode::Char(',') => {
app.style = app.style.next();
}
KeyCode::Char('.') => {
app.with_decis = !app.with_decis;
// update clocks
app.timer.set_with_decis(app.with_decis);
app.countdown.set_with_decis(app.with_decis);
app.pomodoro.set_with_decis(app.with_decis);
}
KeyCode::Up => app.footer.set_show_menu(true),
KeyCode::Down => app.footer.set_show_menu(false),
_ => {}
};
};
// Closure to handle `TuiEvent`'s
let mut handle_tui_events = |app: &mut Self, event: events::TuiEvent| -> Result<()> {
if matches!(event, events::TuiEvent::Tick) {
app.app_time = get_app_time();
app.countdown.set_app_time(app.app_time);
app.local_time.set_app_time(app.app_time);
}
// Pipe events into subviews and handle only 'unhandled' events afterwards
if let Some(unhandled) = match app.content {
Content::Countdown => app.countdown.update(event.clone()),
Content::Timer => app.timer.update(event.clone()),
Content::Pomodoro => app.pomodoro.update(event.clone()),
Content::LocalTime => app.local_time.update(event.clone()),
} {
match unhandled {
events::TuiEvent::Render | events::TuiEvent::Resize => {
app.draw(terminal)?;
}
events::TuiEvent::Key(key) => handle_key_event(app, key),
_ => {}
}
}
Ok(())
};
#[allow(unused_variables)] // `app` is used by `--features sound` only
// Closure to handle `AppEvent`'s
let handle_app_events = |app: &mut Self, event: events::AppEvent| -> Result<()> {
match event {
events::AppEvent::ClockDone(type_id, name) => {
debug!("AppEvent::ClockDone");
if app.notification == Toggle::On {
let msg = match type_id {
ClockTypeId::Timer => {
format!("{name} stopped by reaching its maximum value.")
}
_ => format!("{type_id:?} {name} done!"),
};
// notification
let result = notify_rust::Notification::new()
.summary(&msg.to_uppercase())
.show();
if let Err(err) = result {
error!("on_done {name} error: {err}");
}
};
#[cfg(feature = "sound")]
if let Some(path) = app.sound_path.clone() {
_ = Sound::new(path).and_then(|sound| sound.play()).or_else(
|err| -> Result<()> {
error!("Sound error: {:?}", err);
Ok(())
},
);
}
}
}
Ok(())
};
while self.is_running() {
if let Some(event) = events.next().await {
let _ = match event {
events::Event::Terminal(e) => handle_tui_events(&mut self, e),
events::Event::App(e) => handle_app_events(&mut self, e),
};
}
}
Ok(self)
}
fn is_running(&self) -> bool {
self.mode != Mode::Quit
}
fn get_edit_mode(&self) -> AppEditMode {
match self.content {
Content::Countdown => {
if self.countdown.is_clock_edit_mode() {
AppEditMode::Clock
} else if self.countdown.is_time_edit_mode() {
AppEditMode::Time
} else {
AppEditMode::None
}
}
Content::Timer => {
if self.timer.get_clock().is_edit_mode() {
AppEditMode::Clock
} else {
AppEditMode::None
}
}
Content::Pomodoro => {
if self.pomodoro.get_clock().is_edit_mode() {
AppEditMode::Clock
} else {
AppEditMode::None
}
}
Content::LocalTime => AppEditMode::None,
}
}
fn clock_is_running(&self) -> bool {
match self.content {
Content::Countdown => self.countdown.is_running(),
Content::Timer => self.timer.get_clock().is_running(),
Content::Pomodoro => self.pomodoro.get_clock().is_running(),
// `LocalTime` does not use a `Clock`
Content::LocalTime => false,
}
}
fn get_percentage_done(&self) -> Option<u16> {
match self.content {
Content::Countdown => Some(self.countdown.get_clock().get_percentage_done()),
Content::Timer => None,
Content::Pomodoro => Some(self.pomodoro.get_clock().get_percentage_done()),
Content::LocalTime => None,
}
}
fn draw(&mut self, terminal: &mut Terminal) -> Result<()> {
terminal.draw(|frame| {
frame.render_stateful_widget(AppWidget, frame.area(), self);
})?;
Ok(())
}
pub fn to_storage(&self) -> AppStorage {
AppStorage {
content: self.content,
show_menu: self.footer.get_show_menu(),
notification: self.notification,
blink: self.blink,
app_time_format: self.app_time_format,
style: self.style,
with_decis: self.with_decis,
pomodoro_mode: self.pomodoro.get_mode().clone(),
pomodoro_count: self.pomodoro.get_round(),
inital_value_work: Duration::from(*self.pomodoro.get_clock_work().get_initial_value()),
current_value_work: Duration::from(*self.pomodoro.get_clock_work().get_current_value()),
inital_value_pause: Duration::from(
*self.pomodoro.get_clock_pause().get_initial_value(),
),
current_value_pause: Duration::from(
*self.pomodoro.get_clock_pause().get_current_value(),
),
inital_value_countdown: Duration::from(*self.countdown.get_clock().get_initial_value()),
current_value_countdown: Duration::from(
*self.countdown.get_clock().get_current_value(),
),
elapsed_value_countdown: Duration::from(*self.countdown.get_elapsed_value()),
current_value_timer: Duration::from(*self.timer.get_clock().get_current_value()),
footer_app_time: self.footer.app_time_format().is_some().into(),
}
}
}
struct AppWidget;
impl AppWidget {
fn render_content(&self, area: Rect, buf: &mut Buffer, state: &mut App) {
match state.content {
Content::Timer => {
Timer {
style: state.style,
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.timer);
}
Content::Countdown => Countdown {
style: state.style,
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.countdown),
Content::Pomodoro => PomodoroWidget {
style: state.style,
blink: state.blink == Toggle::On,
}
.render(area, buf, &mut state.pomodoro),
Content::LocalTime => {
LocalTimeWidget { style: state.style }.render(area, buf, &mut state.local_time);
}
};
}
}
impl StatefulWidget for AppWidget {
type State = App;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let [v0, v1, v2] = Layout::vertical([
Constraint::Length(1),
Constraint::Percentage(100),
Constraint::Length(if state.footer.get_show_menu() { 5 } else { 1 }),
])
.areas(area);
// header
Header {
percentage: state.get_percentage_done(),
}
.render(v0, buf);
// content
self.render_content(v1, buf, state);
// footer
Footer {
running_clock: state.clock_is_running(),
selected_content: state.content,
app_edit_mode: state.get_edit_mode(),
app_time: state.app_time,
}
.render(v2, buf, &mut state.footer);
}
}