tempo_cli/ui/
timer.rs

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, // in seconds
21    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, // Default 25 minutes (Pomodoro)
32            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            // Update timer state
40            self.update_timer_state().await?;
41
42            terminal.draw(|f| {
43                self.render_timer(f);
44            })?;
45
46            // Handle input
47            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        // Center the content
69        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        // Pulsing border effect (simulated by toggling color intensity or just using primary)
90        // For now, static primary color
91        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), // Project Info
109                Constraint::Min(10),   // Timer Display
110                Constraint::Length(6), // Metadata
111                Constraint::Length(1), // Footer
112            ])
113            .margin(2)
114            .split(inner_area);
115
116        // 1. Project Info
117        self.render_project_context(f, chunks[0]);
118
119        // 2. Timer Display
120        self.render_large_timer(f, chunks[1]);
121
122        // 3. Metadata
123        self.render_metadata(f, chunks[2]);
124
125        // 4. Footer
126        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"; // Ideally fetched from state
131        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        // Center the digit box within the area
174        let centered_area = Layout::default()
175            .direction(Direction::Vertical)
176            .constraints([
177                Constraint::Min(1),
178                Constraint::Length(8), // Box height
179                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), // Box width
188                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),    // Digit
210                Constraint::Length(1), // Label
211            ])
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                ), // In a real TUI we'd use big text here
223            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        // This would sync with the actual session state from the daemon
313        // For now, we'll keep local state
314        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            // Start timer
323            self.start_time = Some(Utc::now());
324            self.paused_at = None;
325        } else if self.paused_at.is_some() {
326            // Resume timer
327            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            // Pause timer
333            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        // In a full implementation, this would show an input dialog
347        // For now, cycle through common durations
348        self.target_duration = match self.target_duration {
349            1500 => 1800, // 25min -> 30min
350            1800 => 2700, // 30min -> 45min
351            2700 => 3600, // 45min -> 1hour
352            3600 => 5400, // 1hour -> 1.5hour
353            5400 => 7200, // 1.5hour -> 2hour
354            _ => 1500,    // Default back to 25min (Pomodoro)
355        };
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}