tempo_cli/ui/
widgets.rs

1use chrono::{DateTime, Local};
2use ratatui::{
3    buffer::Buffer,
4    layout::{Constraint, Direction, Layout, Rect},
5    style::{Color, Style},
6    text::{Line, Span},
7    widgets::{Block, BorderType, Borders, Paragraph, Widget},
8};
9use std::time::{Duration, Instant};
10
11use crate::ui::formatter::Formatter;
12
13pub struct StatusWidget;
14pub struct ProgressWidget;
15pub struct SummaryWidget;
16
17// Centralized color scheme with a Neon/Cyberpunk aesthetic
18pub struct ColorScheme;
19
20impl ColorScheme {
21    // Vibrant Colors
22    pub const NEON_CYAN: Color = Color::Rgb(0, 255, 255);
23    pub const NEON_GREEN: Color = Color::Rgb(57, 255, 20);
24    pub const NEON_PINK: Color = Color::Rgb(255, 16, 240);
25    pub const NEON_PURPLE: Color = Color::Rgb(188, 19, 254);
26    pub const NEON_YELLOW: Color = Color::Rgb(255, 240, 31);
27    pub const DARK_BG: Color = Color::Rgb(10, 10, 15);
28    pub const GRAY_TEXT: Color = Color::Rgb(160, 160, 160);
29    pub const WHITE_TEXT: Color = Color::Rgb(240, 240, 240);
30
31    // Professional clean palette
32    pub const CLEAN_BG: Color = Color::Rgb(20, 20, 20);
33    pub const CLEAN_ACCENT: Color = Color::Rgb(217, 119, 87); // Terracotta-ish
34    pub const CLEAN_BLUE: Color = Color::Rgb(100, 150, 255);
35    pub const CLEAN_GREEN: Color = Color::Rgb(100, 200, 100);
36    pub const CLEAN_GOLD: Color = Color::Rgb(217, 179, 87);
37    pub const CLEAN_MAGENTA: Color = Color::Rgb(188, 19, 254);
38
39    pub fn get_context_color(context: &str) -> Color {
40        match context {
41            "terminal" => Self::NEON_CYAN,
42            "ide" => Self::NEON_PURPLE,
43            "linked" => Self::NEON_YELLOW,
44            "manual" => Color::Blue,
45            _ => Self::WHITE_TEXT,
46        }
47    }
48
49    pub fn active_status() -> Color {
50        Self::NEON_GREEN
51    }
52    pub fn project_name() -> Color {
53        Self::NEON_YELLOW
54    }
55    pub fn duration() -> Color {
56        Self::NEON_CYAN
57    }
58    pub fn path() -> Color {
59        Self::GRAY_TEXT
60    }
61    pub fn timestamp() -> Color {
62        Self::GRAY_TEXT
63    }
64    pub fn border() -> Color {
65        Self::NEON_PURPLE
66    }
67    pub fn title() -> Color {
68        Self::NEON_PINK
69    }
70
71    pub fn base_block() -> Block<'static> {
72        Block::default()
73            .borders(Borders::ALL)
74            .border_style(Style::default().fg(Self::border()))
75            .border_type(BorderType::Rounded)
76            .style(Style::default().bg(Self::DARK_BG))
77    }
78
79    pub fn clean_block() -> Block<'static> {
80        Block::default()
81            .borders(Borders::NONE)
82            .style(Style::default().bg(Self::DARK_BG))
83    }
84}
85
86pub struct Spinner {
87    frames: Vec<&'static str>,
88    current_frame: usize,
89    last_update: Instant,
90    interval: Duration,
91}
92
93impl Spinner {
94    pub fn new() -> Self {
95        Self {
96            frames: vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
97            current_frame: 0,
98            last_update: Instant::now(),
99            interval: Duration::from_millis(100),
100        }
101    }
102
103    pub fn with_speed(mut self, interval: Duration) -> Self {
104        self.interval = interval;
105        self
106    }
107
108    pub fn next(&mut self) {
109        if self.last_update.elapsed() >= self.interval {
110            self.current_frame = (self.current_frame + 1) % self.frames.len();
111            self.last_update = Instant::now();
112        }
113    }
114
115    pub fn current(&self) -> &str {
116        self.frames[self.current_frame]
117    }
118}
119
120pub struct Throbber {
121    frames: Vec<&'static str>,
122    current: usize,
123}
124
125impl Throbber {
126    pub fn new() -> Self {
127        Self {
128            // A horizontal throbber using ASCII
129            frames: vec![
130                "[=    ]", "[ =   ]", "[  =  ]", "[   = ]", "[    =]", "[   = ]", "[  =  ]",
131                "[ =   ]",
132            ],
133            current: 0,
134        }
135    }
136
137    pub fn next(&mut self) -> &'static str {
138        let frame = self.frames[self.current];
139        self.current = (self.current + 1) % self.frames.len();
140        frame
141    }
142
143    pub fn current(&self) -> &'static str {
144        self.frames[self.current]
145    }
146}
147
148impl StatusWidget {
149    pub fn render_status_text(
150        project_name: &str,
151        duration: i64,
152        start_time: &str,
153        context: &str,
154    ) -> String {
155        format!(
156            "ACTIVE | {} | Time: {} | Started: {} | Context: {}",
157            project_name,
158            Formatter::format_duration(duration),
159            start_time,
160            context
161        )
162    }
163
164    pub fn render_idle_text() -> String {
165        "IDLE | No active time tracking session | Use 'tempo session start' to begin tracking"
166            .to_string()
167    }
168}
169
170impl ProgressWidget {
171    pub fn calculate_daily_progress(completed_seconds: i64, target_hours: f64) -> u16 {
172        let total_hours = completed_seconds as f64 / 3600.0;
173        let progress = (total_hours / target_hours * 100.0).min(100.0) as u16;
174        progress
175    }
176
177    pub fn format_progress_label(completed_seconds: i64, target_hours: f64) -> String {
178        let total_hours = completed_seconds as f64 / 3600.0;
179        let progress = (total_hours / target_hours * 100.0).min(100.0) as u16;
180        format!(
181            "Daily Progress ({:.1}h / {:.1}h) - {}%",
182            total_hours, target_hours, progress
183        )
184    }
185}
186
187impl SummaryWidget {
188    pub fn format_project_summary(
189        project_name: &str,
190        total_time: i64,
191        session_count: usize,
192        active_count: usize,
193    ) -> String {
194        format!(
195            "Project: {} | Total Time: {} | Sessions: {} total, {} active",
196            project_name,
197            Formatter::format_duration(total_time),
198            session_count,
199            active_count
200        )
201    }
202    pub fn format_session_line(
203        start_time: &DateTime<Local>,
204        duration: i64,
205        project_name: &str,
206        status: &str,
207    ) -> String {
208        format!(
209            "{} - {} ({}) [{}]",
210            start_time.format("%H:%M"),
211            project_name,
212            Formatter::format_duration(duration),
213            status
214        )
215    }
216}
217
218#[allow(dead_code)]
219pub enum StatusIndicator {
220    Online,
221    Offline,
222    Syncing,
223    Error,
224    Custom(String, Color),
225}
226
227impl StatusIndicator {
228    #[allow(dead_code)]
229    pub fn render(&self) -> Span {
230        match self {
231            StatusIndicator::Online => Span::styled("●", Style::default().fg(Color::Green)),
232            StatusIndicator::Offline => Span::styled("○", Style::default().fg(Color::Gray)),
233            StatusIndicator::Syncing => Span::styled("⟳", Style::default().fg(Color::Blue)),
234            StatusIndicator::Error => Span::styled("⚠", Style::default().fg(Color::Red)),
235            StatusIndicator::Custom(symbol, color) => {
236                Span::styled(symbol.clone(), Style::default().fg(*color))
237            }
238        }
239    }
240}
241
242#[allow(dead_code)]
243pub struct GradientProgressBar;
244
245impl GradientProgressBar {
246    #[allow(dead_code)]
247    pub fn get_color(progress: u16) -> Color {
248        match progress {
249            0..=25 => Color::Red,
250            26..=50 => Color::Yellow,
251            51..=75 => Color::Green,
252            _ => Color::Cyan,
253        }
254    }
255
256    #[allow(dead_code)]
257    pub fn render(progress: u16, width: u16) -> Line<'static> {
258        let filled_width = (width as f64 * (progress as f64 / 100.0)).round() as u16;
259        let empty_width = width.saturating_sub(filled_width);
260
261        let color = Self::get_color(progress);
262
263        let filled = Span::styled(
264            "█".repeat(filled_width as usize),
265            Style::default().fg(color),
266        );
267        let empty = Span::styled(
268            "░".repeat(empty_width as usize),
269            Style::default().fg(Color::DarkGray),
270        );
271
272        Line::from(vec![filled, empty])
273    }
274}
275
276#[allow(dead_code)]
277pub struct SessionStatsWidget;
278
279impl SessionStatsWidget {
280    #[allow(dead_code)]
281    pub fn render(
282        daily_stats: &(i64, i64, i64), // (sessions, total_time, active_time)
283        weekly_total: i64,
284        area: Rect,
285        buf: &mut Buffer,
286    ) {
287        let (daily_sessions, daily_total, _) = daily_stats;
288
289        let block = Block::default()
290            .borders(Borders::ALL)
291            .border_style(Style::default().fg(ColorScheme::border()))
292            .title(Span::styled(
293                " Session Stats ",
294                Style::default().fg(ColorScheme::title()),
295            ));
296
297        let inner_area = block.inner(area);
298        block.render(area, buf);
299
300        let layout = Layout::default()
301            .direction(Direction::Vertical)
302            .constraints([
303                Constraint::Length(1), // Daily
304                Constraint::Length(1), // Weekly
305                Constraint::Min(0),
306            ])
307            .split(inner_area);
308
309        // Daily Stats
310        let daily_text = Line::from(vec![
311            Span::styled("Today: ", Style::default().fg(ColorScheme::GRAY_TEXT)),
312            Span::styled(
313                format!("{} sessions, ", daily_sessions),
314                Style::default().fg(ColorScheme::WHITE_TEXT),
315            ),
316            Span::styled(
317                Formatter::format_duration(*daily_total),
318                Style::default().fg(ColorScheme::NEON_CYAN),
319            ),
320        ]);
321        Paragraph::new(daily_text).render(layout[0], buf);
322
323        // Weekly Stats
324        let weekly_text = Line::from(vec![
325            Span::styled("This Week: ", Style::default().fg(ColorScheme::GRAY_TEXT)),
326            Span::styled(
327                Formatter::format_duration(weekly_total),
328                Style::default().fg(ColorScheme::NEON_PURPLE),
329            ),
330        ]);
331        Paragraph::new(weekly_text).render(layout[1], buf);
332    }
333}