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