1use anyhow::Result;
2use chrono::{DateTime, Duration, Local, Utc};
3use crossterm::event::{self, Event, KeyCode, KeyEventKind};
4use ratatui::{
5 backend::Backend,
6 layout::{Alignment, Constraint, Direction, Layout, Rect},
7 style::{Modifier, Style},
8 text::{Line, Span},
9 widgets::{Block, Borders, Paragraph},
10 Frame, Terminal,
11};
12use std::time::Duration as StdDuration;
13
14use crate::ui::widgets::{ColorScheme, Throbber};
15
16pub struct InteractiveTimer {
17 start_time: Option<DateTime<Utc>>,
18 paused_at: Option<DateTime<Utc>>,
19 total_paused: Duration,
20 target_duration: i64, show_milestones: bool,
22 throbber: Throbber,
23}
24
25impl InteractiveTimer {
26 pub async fn new() -> Result<Self> {
27 Ok(Self {
28 start_time: None,
29 paused_at: None,
30 total_paused: Duration::zero(),
31 target_duration: 25 * 60, show_milestones: true,
33 throbber: Throbber::new(),
34 })
35 }
36
37 pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
38 loop {
39 self.update_timer_state().await?;
41
42 terminal.draw(|f| {
43 self.render_timer(f);
44 })?;
45
46 if event::poll(StdDuration::from_millis(100))? {
48 match event::read()? {
49 Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
50 KeyCode::Char('q') | KeyCode::Esc => break,
51 KeyCode::Char(' ') => self.toggle_timer().await?,
52 KeyCode::Char('r') => self.reset_timer().await?,
53 KeyCode::Char('s') => self.set_target().await?,
54 KeyCode::Char('m') => self.show_milestones = !self.show_milestones,
55 _ => {}
56 },
57 _ => {}
58 }
59 }
60 }
61
62 Ok(())
63 }
64
65 fn render_timer(&self, f: &mut Frame) {
66 let area = f.size();
67
68 let vertical_center = Layout::default()
70 .direction(Direction::Vertical)
71 .constraints([
72 Constraint::Percentage(15),
73 Constraint::Percentage(70),
74 Constraint::Percentage(15),
75 ])
76 .split(area);
77
78 let horizontal_center = Layout::default()
79 .direction(Direction::Horizontal)
80 .constraints([
81 Constraint::Percentage(15),
82 Constraint::Percentage(70),
83 Constraint::Percentage(15),
84 ])
85 .split(vertical_center[1]);
86
87 let main_area = horizontal_center[1];
88
89 let border_color = if self.paused_at.is_some() || self.start_time.is_none() {
92 ColorScheme::BORDER_DARK
93 } else {
94 ColorScheme::PRIMARY_FOCUS
95 };
96
97 let block = Block::default()
98 .borders(Borders::ALL)
99 .border_style(Style::default().fg(border_color))
100 .style(Style::default().bg(ColorScheme::BG_DARK));
101
102 f.render_widget(block.clone(), main_area);
103 let inner_area = block.inner(main_area);
104
105 let chunks = Layout::default()
106 .direction(Direction::Vertical)
107 .constraints([
108 Constraint::Length(4), Constraint::Min(10), Constraint::Length(6), Constraint::Length(1), ])
113 .margin(2)
114 .split(inner_area);
115
116 self.render_project_context(f, chunks[0]);
118
119 self.render_large_timer(f, chunks[1]);
121
122 self.render_metadata(f, chunks[2]);
124
125 self.render_footer(f, chunks[3]);
127 }
128
129 fn render_project_context(&self, f: &mut Frame, area: Rect) {
130 let project_name = "Current Project"; let description = "Deep Work Session";
132
133 let text = vec![
134 Line::from(Span::styled(
135 project_name,
136 Style::default()
137 .fg(ColorScheme::PRIMARY_FOCUS)
138 .add_modifier(Modifier::BOLD)
139 .add_modifier(Modifier::UNDERLINED),
140 )),
141 Line::from(""),
142 Line::from(Span::styled(
143 description,
144 Style::default().fg(ColorScheme::TEXT_SECONDARY),
145 )),
146 ];
147
148 f.render_widget(Paragraph::new(text).alignment(Alignment::Left), area);
149 }
150
151 fn render_large_timer(&self, f: &mut Frame, area: Rect) {
152 let elapsed = self.get_elapsed_time();
153
154 let hours = elapsed / 3600;
155 let minutes = (elapsed % 3600) / 60;
156 let seconds = elapsed % 60;
157
158 let layout = Layout::default()
159 .direction(Direction::Horizontal)
160 .constraints([
161 Constraint::Ratio(1, 3),
162 Constraint::Ratio(1, 3),
163 Constraint::Ratio(1, 3),
164 ])
165 .split(area);
166
167 self.render_timer_digit(f, layout[0], hours, "HOURS");
168 self.render_timer_digit(f, layout[1], minutes, "MINUTES");
169 self.render_timer_digit(f, layout[2], seconds, "SECONDS");
170 }
171
172 fn render_timer_digit(&self, f: &mut Frame, area: Rect, value: i64, label: &str) {
173 let centered_area = Layout::default()
175 .direction(Direction::Vertical)
176 .constraints([
177 Constraint::Min(1),
178 Constraint::Length(8), Constraint::Min(1),
180 ])
181 .split(area)[1];
182
183 let centered_area = Layout::default()
184 .direction(Direction::Horizontal)
185 .constraints([
186 Constraint::Min(1),
187 Constraint::Length(14), Constraint::Min(1),
189 ])
190 .split(centered_area)[1];
191
192 let block = Block::default()
193 .borders(Borders::ALL)
194 .border_style(Style::default().fg(
195 if self.start_time.is_some() && self.paused_at.is_none() {
196 ColorScheme::PRIMARY_FOCUS
197 } else {
198 ColorScheme::BORDER_DARK
199 },
200 ))
201 .style(Style::default().bg(ColorScheme::PANEL_DARK));
202
203 f.render_widget(block.clone(), centered_area);
204 let inner = block.inner(centered_area);
205
206 let content_layout = Layout::default()
207 .direction(Direction::Vertical)
208 .constraints([
209 Constraint::Min(1), Constraint::Length(1), ])
212 .margin(1)
213 .split(inner);
214
215 f.render_widget(
216 Paragraph::new(format!("{:02}", value))
217 .alignment(Alignment::Center)
218 .style(
219 Style::default()
220 .fg(ColorScheme::TEXT_MAIN)
221 .add_modifier(Modifier::BOLD),
222 ), content_layout[0],
224 );
225
226 f.render_widget(
227 Paragraph::new(label)
228 .alignment(Alignment::Center)
229 .style(Style::default().fg(ColorScheme::TEXT_SECONDARY)),
230 content_layout[1],
231 );
232 }
233
234 fn render_metadata(&self, f: &mut Frame, area: Rect) {
235 let start_time_str = if let Some(start) = self.start_time {
236 start.with_timezone(&Local).format("%H:%M").to_string()
237 } else {
238 "--:--".to_string()
239 };
240
241 let layout = Layout::default()
242 .direction(Direction::Horizontal)
243 .constraints([
244 Constraint::Percentage(33),
245 Constraint::Percentage(33),
246 Constraint::Percentage(33),
247 ])
248 .split(area);
249
250 let items = [
251 ("START TIME", start_time_str),
252 ("SESSION TYPE", "Focus".to_string()),
253 ("TAGS", "coding, rust".to_string()),
254 ];
255
256 for (i, (label, value)) in items.iter().enumerate() {
257 let text = vec![
258 Line::from(Span::styled(
259 *label,
260 Style::default().fg(ColorScheme::TEXT_SECONDARY),
261 )),
262 Line::from(Span::styled(
263 value.as_str(),
264 Style::default()
265 .fg(ColorScheme::TEXT_MAIN)
266 .add_modifier(Modifier::BOLD),
267 )),
268 ];
269 f.render_widget(Paragraph::new(text).alignment(Alignment::Center), layout[i]);
270 }
271 }
272
273 fn render_footer(&self, f: &mut Frame, area: Rect) {
274 let hints = vec![
275 Span::styled(
276 "[Space]",
277 Style::default()
278 .fg(ColorScheme::PRIMARY_FOCUS)
279 .add_modifier(Modifier::BOLD),
280 ),
281 Span::raw(" Toggle "),
282 Span::styled(
283 "[R]",
284 Style::default()
285 .fg(ColorScheme::PRIMARY_FOCUS)
286 .add_modifier(Modifier::BOLD),
287 ),
288 Span::raw(" Reset "),
289 Span::styled(
290 "[S]",
291 Style::default()
292 .fg(ColorScheme::PRIMARY_FOCUS)
293 .add_modifier(Modifier::BOLD),
294 ),
295 Span::raw(" Set Target "),
296 Span::styled(
297 "[Q]",
298 Style::default()
299 .fg(ColorScheme::ERROR)
300 .add_modifier(Modifier::BOLD),
301 ),
302 Span::raw(" Quit"),
303 ];
304
305 f.render_widget(
306 Paragraph::new(Line::from(hints)).alignment(Alignment::Center),
307 area,
308 );
309 }
310
311 async fn update_timer_state(&mut self) -> Result<()> {
312 if self.start_time.is_some() && self.paused_at.is_none() {
315 self.throbber.next();
316 }
317 Ok(())
318 }
319
320 async fn toggle_timer(&mut self) -> Result<()> {
321 if self.start_time.is_none() {
322 self.start_time = Some(Utc::now());
324 self.paused_at = None;
325 } else if self.paused_at.is_some() {
326 if let Some(paused_at) = self.paused_at {
328 self.total_paused += Utc::now() - paused_at;
329 }
330 self.paused_at = None;
331 } else {
332 self.paused_at = Some(Utc::now());
334 }
335 Ok(())
336 }
337
338 async fn reset_timer(&mut self) -> Result<()> {
339 self.start_time = None;
340 self.paused_at = None;
341 self.total_paused = chrono::Duration::zero();
342 Ok(())
343 }
344
345 async fn set_target(&mut self) -> Result<()> {
346 self.target_duration = match self.target_duration {
349 1500 => 1800, 1800 => 2700, 2700 => 3600, 3600 => 5400, 5400 => 7200, _ => 1500, };
356 Ok(())
357 }
358
359 fn get_elapsed_time(&self) -> i64 {
360 if let Some(start) = self.start_time {
361 let end_time = if let Some(paused) = self.paused_at {
362 paused
363 } else {
364 Utc::now()
365 };
366
367 (end_time - start - self.total_paused).num_seconds().max(0)
368 } else {
369 0
370 }
371 }
372}