use anyhow::Result;
use chrono::{DateTime, Duration, Local, Utc};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
use std::time::Duration as StdDuration;
use crate::ui::widgets::{ColorScheme, Throbber};
pub struct InteractiveTimer {
start_time: Option<DateTime<Utc>>,
paused_at: Option<DateTime<Utc>>,
total_paused: Duration,
target_duration: i64, show_milestones: bool,
throbber: Throbber,
}
impl InteractiveTimer {
pub async fn new() -> Result<Self> {
Ok(Self {
start_time: None,
paused_at: None,
total_paused: Duration::zero(),
target_duration: 25 * 60, show_milestones: true,
throbber: Throbber::new(),
})
}
pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
loop {
self.update_timer_state().await?;
terminal.draw(|f| {
self.render_timer(f);
})?;
if event::poll(StdDuration::from_millis(100))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char(' ') => self.toggle_timer().await?,
KeyCode::Char('r') => self.reset_timer().await?,
KeyCode::Char('s') => self.set_target().await?,
KeyCode::Char('m') => self.show_milestones = !self.show_milestones,
_ => {}
},
_ => {}
}
}
}
Ok(())
}
fn render_timer(&self, f: &mut Frame) {
let area = f.size();
let vertical_center = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(15),
Constraint::Percentage(70),
Constraint::Percentage(15),
])
.split(area);
let horizontal_center = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(15),
Constraint::Percentage(70),
Constraint::Percentage(15),
])
.split(vertical_center[1]);
let main_area = horizontal_center[1];
let border_color = if self.paused_at.is_some() || self.start_time.is_none() {
ColorScheme::BORDER_DARK
} else {
ColorScheme::PRIMARY_FOCUS
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(ColorScheme::BG_DARK));
f.render_widget(block.clone(), main_area);
let inner_area = block.inner(main_area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4), Constraint::Min(10), Constraint::Length(6), Constraint::Length(1), ])
.margin(2)
.split(inner_area);
self.render_project_context(f, chunks[0]);
self.render_large_timer(f, chunks[1]);
self.render_metadata(f, chunks[2]);
self.render_footer(f, chunks[3]);
}
fn render_project_context(&self, f: &mut Frame, area: Rect) {
let project_name = "Current Project"; let description = "Deep Work Session";
let text = vec![
Line::from(Span::styled(
project_name,
Style::default()
.fg(ColorScheme::PRIMARY_FOCUS)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::UNDERLINED),
)),
Line::from(""),
Line::from(Span::styled(
description,
Style::default().fg(ColorScheme::TEXT_SECONDARY),
)),
];
f.render_widget(Paragraph::new(text).alignment(Alignment::Left), area);
}
fn render_large_timer(&self, f: &mut Frame, area: Rect) {
let elapsed = self.get_elapsed_time();
let hours = elapsed / 3600;
let minutes = (elapsed % 3600) / 60;
let seconds = elapsed % 60;
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(area);
self.render_timer_digit(f, layout[0], hours, "HOURS");
self.render_timer_digit(f, layout[1], minutes, "MINUTES");
self.render_timer_digit(f, layout[2], seconds, "SECONDS");
}
fn render_timer_digit(&self, f: &mut Frame, area: Rect, value: i64, label: &str) {
let centered_area = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(8), Constraint::Min(1),
])
.split(area)[1];
let centered_area = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(1),
Constraint::Length(14), Constraint::Min(1),
])
.split(centered_area)[1];
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(
if self.start_time.is_some() && self.paused_at.is_none() {
ColorScheme::PRIMARY_FOCUS
} else {
ColorScheme::BORDER_DARK
},
))
.style(Style::default().bg(ColorScheme::PANEL_DARK));
f.render_widget(block.clone(), centered_area);
let inner = block.inner(centered_area);
let content_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), Constraint::Length(1), ])
.margin(1)
.split(inner);
f.render_widget(
Paragraph::new(format!("{:02}", value))
.alignment(Alignment::Center)
.style(
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD),
), content_layout[0],
);
f.render_widget(
Paragraph::new(label)
.alignment(Alignment::Center)
.style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
content_layout[1],
);
}
fn render_metadata(&self, f: &mut Frame, area: Rect) {
let start_time_str = if let Some(start) = self.start_time {
start.with_timezone(&Local).format("%H:%M").to_string()
} else {
"--:--".to_string()
};
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(33),
])
.split(area);
let items = [
("START TIME", start_time_str),
("SESSION TYPE", "Focus".to_string()),
("TAGS", "coding, rust".to_string()),
];
for (i, (label, value)) in items.iter().enumerate() {
let text = vec![
Line::from(Span::styled(
*label,
Style::default().fg(ColorScheme::TEXT_SECONDARY),
)),
Line::from(Span::styled(
value.as_str(),
Style::default()
.fg(ColorScheme::TEXT_MAIN)
.add_modifier(Modifier::BOLD),
)),
];
f.render_widget(Paragraph::new(text).alignment(Alignment::Center), layout[i]);
}
}
fn render_footer(&self, f: &mut Frame, area: Rect) {
let hints = vec![
Span::styled(
"[Space]",
Style::default()
.fg(ColorScheme::PRIMARY_FOCUS)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Toggle "),
Span::styled(
"[R]",
Style::default()
.fg(ColorScheme::PRIMARY_FOCUS)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Reset "),
Span::styled(
"[S]",
Style::default()
.fg(ColorScheme::PRIMARY_FOCUS)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Set Target "),
Span::styled(
"[Q]",
Style::default()
.fg(ColorScheme::ERROR)
.add_modifier(Modifier::BOLD),
),
Span::raw(" Quit"),
];
f.render_widget(
Paragraph::new(Line::from(hints)).alignment(Alignment::Center),
area,
);
}
async fn update_timer_state(&mut self) -> Result<()> {
if self.start_time.is_some() && self.paused_at.is_none() {
self.throbber.next();
}
Ok(())
}
async fn toggle_timer(&mut self) -> Result<()> {
if self.start_time.is_none() {
self.start_time = Some(Utc::now());
self.paused_at = None;
} else if self.paused_at.is_some() {
if let Some(paused_at) = self.paused_at {
self.total_paused += Utc::now() - paused_at;
}
self.paused_at = None;
} else {
self.paused_at = Some(Utc::now());
}
Ok(())
}
async fn reset_timer(&mut self) -> Result<()> {
self.start_time = None;
self.paused_at = None;
self.total_paused = chrono::Duration::zero();
Ok(())
}
async fn set_target(&mut self) -> Result<()> {
self.target_duration = match self.target_duration {
1500 => 1800, 1800 => 2700, 2700 => 3600, 3600 => 5400, 5400 => 7200, _ => 1500, };
Ok(())
}
fn get_elapsed_time(&self) -> i64 {
if let Some(start) = self.start_time {
let end_time = if let Some(paused) = self.paused_at {
paused
} else {
Utc::now()
};
(end_time - start - self.total_paused).num_seconds().max(0)
} else {
0
}
}
}