git_contribution_analyzer/
ui.rs

1use crate::{
2    app::{App, AuthorSummary},
3    git::Contribution,
4};
5use std::io;
6use tui::{
7    backend::CrosstermBackend,
8    layout::{Constraint, Direction, Layout, Rect},
9    style::{Color, Modifier, Style},
10    text::Spans,
11    widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs},
12    Frame,
13};
14
15pub fn render_loading_screen(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &App) {
16    let size = f.size();
17
18    let block = Block::default()
19        .title("Git Contribution Analyzer")
20        .borders(Borders::ALL);
21    f.render_widget(block, size);
22
23    let loading_text = format!(
24        "{} {}",
25        app.loading_message,
26        ".".repeat(((app.loading_progress % 4) + 1) as usize)
27    );
28
29    let loading_paragraph = Paragraph::new(loading_text)
30        .style(Style::default().fg(Color::Cyan))
31        .block(Block::default())
32        .alignment(tui::layout::Alignment::Center);
33
34    let loading_area = centered_rect(60, 20, size);
35    f.render_widget(loading_paragraph, loading_area);
36}
37
38pub fn render_main_view(f: &mut Frame<CrosstermBackend<io::Stdout>>, app: &App) {
39    let size = f.size();
40
41    let main_block = Block::default()
42        .title("Git Contribution Analyzer")
43        .borders(Borders::ALL);
44    f.render_widget(main_block, size);
45
46    let chunks = Layout::default()
47        .direction(Direction::Vertical)
48        .constraints([
49            Constraint::Length(3), // Tabs
50            Constraint::Min(10),   // Content
51            Constraint::Length(3), // Help
52        ])
53        .split(size);
54
55    let mut tab_titles = app
56        .repositories
57        .iter()
58        .map(|repo| Spans::from(repo.clone()))
59        .collect::<Vec<Spans>>();
60
61    tab_titles.push(Spans::from("Summary"));
62
63    let tabs = Tabs::new(tab_titles)
64        .block(Block::default().borders(Borders::ALL).title("Repositories"))
65        .select(app.current_tab)
66        .style(Style::default())
67        .highlight_style(
68            Style::default()
69                .fg(Color::Yellow)
70                .add_modifier(Modifier::BOLD),
71        );
72
73    f.render_widget(tabs, chunks[0]);
74
75    if app.current_tab < app.repositories.len() {
76        let repo_name = &app.repositories[app.current_tab];
77        if let Some(contributions) = app.contributions.get(repo_name) {
78            render_repository_tab(
79                f,
80                chunks[1],
81                repo_name,
82                contributions,
83                app.selected_in_tab[app.current_tab],
84            );
85        }
86    } else {
87        render_summary_tab(
88            f,
89            chunks[1],
90            &app.author_summaries,
91            app.selected_in_tab[app.current_tab],
92        );
93    }
94
95    if app.show_help {
96        render_help(f, chunks[2]);
97    } else {
98        render_help_shortcut(f, chunks[2]);
99    }
100}
101
102pub fn render_repository_tab(
103    f: &mut Frame<CrosstermBackend<io::Stdout>>,
104    area: Rect,
105    repo_name: &str,
106    contributions: &[Contribution],
107    selected: Option<usize>,
108) {
109    let header_cells = [
110        "Author",
111        "Email",
112        "Commits",
113        "Lines Added",
114        "Lines Deleted",
115        "Contribution %",
116    ]
117    .iter()
118    .map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow)));
119
120    let header = Row::new(header_cells).style(Style::default()).height(1);
121
122    let rows = contributions.iter().enumerate().map(|(i, c)| {
123        let style = if Some(i) == selected {
124            Style::default().add_modifier(Modifier::REVERSED)
125        } else {
126            Style::default()
127        };
128
129        let cells = [
130            Cell::from(c.author.clone()),
131            Cell::from(c.email.clone()),
132            Cell::from(c.commits.to_string()),
133            Cell::from(c.lines_added.to_string()),
134            Cell::from(c.lines_deleted.to_string()),
135            Cell::from(format!("{:.2}%", c.contribution_percent)),
136        ];
137
138        Row::new(cells).style(style).height(1)
139    });
140
141    let table = Table::new(rows)
142        .header(header)
143        .block(
144            Block::default()
145                .title(format!("Repository: {}", repo_name))
146                .borders(Borders::ALL),
147        )
148        .widths(&[
149            Constraint::Percentage(20),
150            Constraint::Percentage(30),
151            Constraint::Percentage(10),
152            Constraint::Percentage(13),
153            Constraint::Percentage(13),
154            Constraint::Percentage(14),
155        ])
156        .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
157        .highlight_symbol("> ");
158
159    f.render_widget(table, area);
160}
161
162pub fn render_summary_tab(
163    f: &mut Frame<CrosstermBackend<io::Stdout>>,
164    area: Rect,
165    summaries: &[AuthorSummary],
166    selected: Option<usize>,
167) {
168    let header_cells = [
169        "Author",
170        "Email",
171        "Total Commits",
172        "Lines Added",
173        "Lines Deleted",
174        "Overall %",
175        "Preferred Repo",
176        "Preferred %",
177    ]
178    .iter()
179    .map(|h| Cell::from(*h).style(Style::default().fg(Color::Yellow)));
180
181    let header = Row::new(header_cells).style(Style::default()).height(1);
182
183    let rows = summaries.iter().enumerate().map(|(i, s)| {
184        let style = if Some(i) == selected {
185            Style::default().add_modifier(Modifier::REVERSED)
186        } else {
187            Style::default()
188        };
189
190        let cells = [
191            Cell::from(s.author.clone()),
192            Cell::from(s.email.clone()),
193            Cell::from(s.total_commits.to_string()),
194            Cell::from(s.total_lines_added.to_string()),
195            Cell::from(s.total_lines_deleted.to_string()),
196            Cell::from(format!("{:.2}%", s.overall_contribution_percent)),
197            Cell::from(s.preferred_repo.clone()),
198            Cell::from(format!("{:.2}%", s.preferred_repo_percent)),
199        ];
200
201        Row::new(cells).style(style).height(1)
202    });
203
204    let table = Table::new(rows)
205        .header(header)
206        .block(
207            Block::default()
208                .title("Summary Across All Repositories")
209                .borders(Borders::ALL),
210        )
211        .widths(&[
212            Constraint::Percentage(15),
213            Constraint::Percentage(20),
214            Constraint::Percentage(10),
215            Constraint::Percentage(10),
216            Constraint::Percentage(10),
217            Constraint::Percentage(10),
218            Constraint::Percentage(15),
219            Constraint::Percentage(10),
220        ])
221        .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
222        .highlight_symbol("> ");
223
224    f.render_widget(table, area);
225}
226
227pub fn render_help_shortcut(f: &mut Frame<CrosstermBackend<io::Stdout>>, area: Rect) {
228    let help_text = "Press '?' to show help";
229    let help_paragraph = Paragraph::new(help_text)
230        .style(Style::default().fg(Color::Gray))
231        .alignment(tui::layout::Alignment::Center)
232        .block(Block::default().borders(Borders::ALL));
233
234    f.render_widget(help_paragraph, area);
235}
236
237pub fn render_help(f: &mut Frame<CrosstermBackend<io::Stdout>>, area: Rect) {
238    let help_text = vec![
239        Spans::from("↑/↓: Navigate entries | Tab/Shift+Tab: Switch repositories"),
240        Spans::from("?: Toggle help | q: Quit | h: Export HTML report"),
241    ];
242
243    let help_paragraph = Paragraph::new(help_text)
244        .style(Style::default())
245        .alignment(tui::layout::Alignment::Center)
246        .block(Block::default().borders(Borders::ALL).title("Help"));
247
248    f.render_widget(help_paragraph, area);
249}
250
251pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
252    let popup_layout = Layout::default()
253        .direction(Direction::Vertical)
254        .constraints([
255            Constraint::Percentage((100 - percent_y) / 2),
256            Constraint::Percentage(percent_y),
257            Constraint::Percentage((100 - percent_y) / 2),
258        ])
259        .split(r);
260
261    Layout::default()
262        .direction(Direction::Horizontal)
263        .constraints([
264            Constraint::Percentage((100 - percent_x) / 2),
265            Constraint::Percentage(percent_x),
266            Constraint::Percentage((100 - percent_x) / 2),
267        ])
268        .split(popup_layout[1])[1]
269}