tempo_cli/ui/
timer.rs

1use anyhow::Result;
2use chrono::{DateTime, Duration, Utc};
3use crossterm::event::{self, Event, KeyCode, KeyEventKind};
4use ratatui::{
5    backend::Backend,
6    layout::{Alignment, Constraint, Direction, Layout, Rect},
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9    widgets::{Gauge, Paragraph, Wrap},
10    Frame, Terminal,
11};
12use std::time::Duration as StdDuration;
13
14use crate::{
15    ui::{
16        formatter::Formatter,
17        widgets::{ColorScheme, Throbber},
18    },
19    utils::ipc::IpcClient,
20};
21
22pub struct InteractiveTimer {
23    client: IpcClient,
24    start_time: Option<DateTime<Utc>>,
25    paused_at: Option<DateTime<Utc>>,
26    total_paused: Duration,
27    target_duration: i64, // in seconds
28    show_milestones: bool,
29    throbber: Throbber,
30}
31
32impl InteractiveTimer {
33    pub async fn new() -> Result<Self> {
34        let socket_path = crate::utils::ipc::get_socket_path()?;
35        let client = if socket_path.exists() {
36            match IpcClient::connect(&socket_path).await {
37                Ok(client) => client,
38                Err(_) => IpcClient::new()?,
39            }
40        } else {
41            IpcClient::new()?
42        };
43
44        Ok(Self {
45            client,
46            start_time: None,
47            paused_at: None,
48            total_paused: Duration::zero(),
49            target_duration: 25 * 60, // Default 25 minutes (Pomodoro)
50            show_milestones: true,
51            throbber: Throbber::new(),
52        })
53    }
54
55    pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
56        loop {
57            // Update timer state
58            self.update_timer_state().await?;
59
60            terminal.draw(|f| {
61                self.render_timer(f);
62            })?;
63
64            // Handle input
65            if event::poll(StdDuration::from_millis(100))? {
66                match event::read()? {
67                    Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
68                        KeyCode::Char('q') | KeyCode::Esc => break,
69                        KeyCode::Char(' ') => self.toggle_timer().await?,
70                        KeyCode::Char('r') => self.reset_timer().await?,
71                        KeyCode::Char('s') => self.set_target().await?,
72                        KeyCode::Char('m') => self.show_milestones = !self.show_milestones,
73                        _ => {}
74                    },
75                    _ => {}
76                }
77            }
78        }
79
80        Ok(())
81    }
82
83    fn render_timer(&self, f: &mut Frame) {
84        let chunks = Layout::default()
85            .direction(Direction::Vertical)
86            .constraints([
87                Constraint::Length(3), // Title
88                Constraint::Length(8), // Timer display
89                Constraint::Length(6), // Progress bar
90                Constraint::Length(6), // Milestones
91                Constraint::Min(0),    // Controls
92            ])
93            .split(f.size());
94
95        // Title
96        // Title
97        let title = Paragraph::new("Interactive Timer")
98            .style(
99                Style::default()
100                    .fg(ColorScheme::CLEAN_ACCENT)
101                    .add_modifier(Modifier::BOLD),
102            )
103            .alignment(Alignment::Center)
104            .block(ColorScheme::clean_block());
105        f.render_widget(title, chunks[0]);
106
107        // Timer display
108        self.render_timer_display(f, chunks[1]);
109
110        // Progress bar
111        self.render_progress_bar(f, chunks[2]);
112
113        // Milestones
114        if self.show_milestones {
115            self.render_milestones(f, chunks[3]);
116        }
117
118        // Controls
119        self.render_controls(f, chunks[4]);
120    }
121
122    fn render_timer_display(&self, f: &mut Frame, area: Rect) {
123        let elapsed = self.get_elapsed_time();
124        let is_running = self.start_time.is_some() && self.paused_at.is_none();
125
126        let time_display = Formatter::format_duration(elapsed);
127        let status = if is_running {
128            "RUNNING"
129        } else if self.start_time.is_some() {
130            "PAUSED"
131        } else {
132            "STOPPED"
133        };
134        let status_color = if is_running {
135            Color::Green
136        } else if self.start_time.is_some() {
137            Color::Yellow
138        } else {
139            Color::Red
140        };
141
142        let timer_text = vec![
143            Line::from(Span::styled(
144                time_display,
145                Style::default()
146                    .fg(ColorScheme::CLEAN_BLUE)
147                    .add_modifier(Modifier::BOLD),
148            )),
149            Line::from(Span::raw("")),
150            Line::from(vec![
151                Span::raw("Status: "),
152                Span::styled(
153                    status,
154                    Style::default()
155                        .fg(status_color)
156                        .add_modifier(Modifier::BOLD),
157                ),
158                if is_running {
159                    Span::styled(
160                        format!("  {}", self.throbber.current()),
161                        Style::default().fg(ColorScheme::CLEAN_ACCENT),
162                    )
163                } else {
164                    Span::raw("")
165                },
166            ]),
167            Line::from(vec![
168                Span::raw("Target: "),
169                Span::styled(
170                    Formatter::format_duration(self.target_duration),
171                    Style::default().fg(ColorScheme::WHITE_TEXT),
172                ),
173            ]),
174        ];
175
176        let timer_block = ColorScheme::clean_block()
177            .title("Timer")
178            .style(Style::default().fg(ColorScheme::WHITE_TEXT));
179
180        let paragraph = Paragraph::new(timer_text)
181            .block(timer_block)
182            .alignment(Alignment::Center)
183            .wrap(Wrap { trim: true });
184        f.render_widget(paragraph, area);
185    }
186
187    fn render_progress_bar(&self, f: &mut Frame, area: Rect) {
188        let elapsed = self.get_elapsed_time();
189        let progress = if self.target_duration > 0 {
190            ((elapsed as f64 / self.target_duration as f64) * 100.0).min(100.0)
191        } else {
192            0.0
193        };
194
195        let progress_color = if progress >= 100.0 {
196            ColorScheme::CLEAN_GREEN
197        } else if progress >= 75.0 {
198            Color::Yellow
199        } else {
200            ColorScheme::CLEAN_ACCENT
201        };
202
203        let progress_bar = Gauge::default()
204            .block(
205                ColorScheme::clean_block()
206                    .title("Progress to Target")
207                    .style(Style::default().fg(ColorScheme::WHITE_TEXT)),
208            )
209            .gauge_style(Style::default().fg(progress_color))
210            .percent(progress as u16)
211            .label(format!(
212                "{:.1}% ({}/{})",
213                progress,
214                Formatter::format_duration(elapsed),
215                Formatter::format_duration(self.target_duration)
216            ));
217
218        f.render_widget(progress_bar, area);
219    }
220
221    fn render_milestones(&self, f: &mut Frame, area: Rect) {
222        let elapsed = self.get_elapsed_time();
223        let milestones = vec![
224            (5 * 60, "5 min warm-up"),
225            (15 * 60, "15 min focus"),
226            (25 * 60, "Pomodoro complete"),
227            (45 * 60, "45 min deep work"),
228            (60 * 60, "1 hour marathon"),
229        ];
230
231        let mut milestone_lines = vec![];
232        for (duration, name) in milestones {
233            let achieved = elapsed >= duration;
234            let icon = if achieved { "[x]" } else { "[ ]" };
235            let style = if achieved {
236                Style::default().fg(ColorScheme::CLEAN_GREEN)
237            } else {
238                Style::default().fg(ColorScheme::GRAY_TEXT)
239            };
240
241            milestone_lines.push(Line::from(vec![Span::styled(
242                format!("{} {}", icon, name),
243                style,
244            )]));
245        }
246
247        let milestones_block = ColorScheme::clean_block()
248            .title("Milestones")
249            .style(Style::default().fg(ColorScheme::WHITE_TEXT));
250
251        let paragraph = Paragraph::new(milestone_lines)
252            .block(milestones_block)
253            .wrap(Wrap { trim: true });
254        f.render_widget(paragraph, area);
255    }
256
257    fn render_controls(&self, f: &mut Frame, area: Rect) {
258        let controls_text = vec![
259            Line::from(Span::styled(
260                "Controls:",
261                Style::default()
262                    .fg(ColorScheme::CLEAN_ACCENT)
263                    .add_modifier(Modifier::BOLD),
264            )),
265            Line::from(Span::raw("Space - Start/Pause timer")),
266            Line::from(Span::raw("R - Reset timer")),
267            Line::from(Span::raw("S - Set target duration")),
268            Line::from(Span::raw("M - Toggle milestones")),
269            Line::from(Span::raw("Q/Esc - Quit")),
270        ];
271
272        let controls_block = ColorScheme::clean_block()
273            .title("Controls")
274            .style(Style::default().fg(ColorScheme::WHITE_TEXT));
275
276        let paragraph = Paragraph::new(controls_text)
277            .block(controls_block)
278            .wrap(Wrap { trim: true });
279        f.render_widget(paragraph, area);
280    }
281
282    async fn update_timer_state(&mut self) -> Result<()> {
283        // This would sync with the actual session state from the daemon
284        // For now, we'll keep local state
285        if self.start_time.is_some() && self.paused_at.is_none() {
286            self.throbber.next();
287        }
288        Ok(())
289    }
290
291    async fn toggle_timer(&mut self) -> Result<()> {
292        if self.start_time.is_none() {
293            // Start timer
294            self.start_time = Some(Utc::now());
295            self.paused_at = None;
296        } else if self.paused_at.is_some() {
297            // Resume timer
298            if let Some(paused_at) = self.paused_at {
299                self.total_paused = self.total_paused + (Utc::now() - paused_at);
300            }
301            self.paused_at = None;
302        } else {
303            // Pause timer
304            self.paused_at = Some(Utc::now());
305        }
306        Ok(())
307    }
308
309    async fn reset_timer(&mut self) -> Result<()> {
310        self.start_time = None;
311        self.paused_at = None;
312        self.total_paused = chrono::Duration::zero();
313        Ok(())
314    }
315
316    async fn set_target(&mut self) -> Result<()> {
317        // In a full implementation, this would show an input dialog
318        // For now, cycle through common durations
319        self.target_duration = match self.target_duration {
320            1500 => 1800, // 25min -> 30min
321            1800 => 2700, // 30min -> 45min
322            2700 => 3600, // 45min -> 1hour
323            3600 => 5400, // 1hour -> 1.5hour
324            5400 => 7200, // 1.5hour -> 2hour
325            _ => 1500,    // Default back to 25min (Pomodoro)
326        };
327        Ok(())
328    }
329
330    fn get_elapsed_time(&self) -> i64 {
331        if let Some(start) = self.start_time {
332            let end_time = if let Some(paused) = self.paused_at {
333                paused
334            } else {
335                Utc::now()
336            };
337
338            (end_time - start - self.total_paused).num_seconds().max(0)
339        } else {
340            0
341        }
342    }
343}