cli_tutor/ui/
progress_overlay.rs1use crate::app::App;
3use ratatui::{
4 layout::{Constraint, Direction, Layout, Rect},
5 style::{Color, Modifier, Style},
6 text::{Line, Span},
7 widgets::{Block, Borders, Clear, Paragraph},
8 Frame,
9};
10
11pub fn render(app: &App, frame: &mut Frame, area: Rect) {
12 let overlay = centered_rect(70, 80, area);
13 frame.render_widget(Clear, overlay);
14
15 let nc = app.config.no_color;
16
17 let streak_label = if app.stats.streak_days == 1 {
20 "day".to_string()
21 } else {
22 "days".to_string()
23 };
24 let xp_streak = format!(
25 "XP: {} Streak: {} {}",
26 app.stats.total_xp, app.stats.streak_days, streak_label
27 );
28
29 let mut lines: Vec<Line<'static>> = vec![
30 Line::from(Span::styled(
31 "Progress Summary",
32 crate::ui::s(
33 Style::default()
34 .fg(Color::Cyan)
35 .add_modifier(Modifier::BOLD),
36 nc,
37 ),
38 )),
39 Line::from(Span::styled(
40 xp_streak,
41 crate::ui::s(Style::default().fg(Color::Yellow), nc),
42 )),
43 Line::from(""),
44 ];
45
46 let show_times = app.config.timed_challenge;
47 let header = if show_times {
48 " Module Completed Best Time"
49 } else {
50 " Module Completed"
51 };
52 lines.push(Line::from(Span::styled(
53 header,
54 crate::ui::s(
55 Style::default().add_modifier(Modifier::UNDERLINED),
56 nc,
57 ),
58 )));
59 lines.push(Line::from(""));
60
61 let mut total_completed = 0usize;
62 let mut total_exercises = 0usize;
63
64 for m in &app.modules {
65 let ex_count = m.exercises.len();
66 let completed = app
67 .progress
68 .modules
69 .get(&m.module.name)
70 .map(|p| p.completed.len())
71 .unwrap_or(0);
72 total_completed += completed;
73 total_exercises += ex_count;
74
75 let pct = if ex_count > 0 {
76 completed * 100 / ex_count
77 } else {
78 0
79 };
80 let bar = progress_bar(pct);
81
82 let time_str = if show_times {
84 let best = m
85 .exercises
86 .iter()
87 .filter_map(|ex| app.progress.best_time(&m.module.name, &ex.id))
88 .min();
89 match best {
90 Some(ms) => format!(" {:.1}s", ms as f64 / 1000.0),
91 None => " —".to_string(),
92 }
93 } else {
94 String::new()
95 };
96
97 let done_mark = if completed == ex_count && ex_count > 0 {
98 " ✓"
99 } else {
100 " "
101 };
102 let color = if completed == ex_count && ex_count > 0 {
103 Color::Green
104 } else if completed > 0 {
105 Color::Yellow
106 } else {
107 Color::DarkGray
108 };
109
110 let row = format!(
111 " {:<16}{}{}/{} {}{}{}",
112 m.module.name,
113 done_mark,
114 completed,
115 ex_count,
116 bar,
117 if pct < 100 {
118 format!(" {pct}%")
119 } else {
120 " ".to_string()
121 },
122 time_str,
123 );
124 lines.push(Line::from(Span::styled(
125 row,
126 crate::ui::s(Style::default().fg(color), nc),
127 )));
128 }
129
130 lines.push(Line::from(""));
132 let total_pct = if total_exercises > 0 {
133 total_completed * 100 / total_exercises
134 } else {
135 0
136 };
137 lines.push(Line::from(Span::styled(
138 format!(
139 " Total {}/{} ({total_pct}%)",
140 total_completed, total_exercises
141 ),
142 crate::ui::s(Style::default().add_modifier(Modifier::BOLD), nc),
143 )));
144 lines.push(Line::from(""));
145 lines.push(Line::from(Span::styled(
146 "Press P to close",
147 crate::ui::s(Style::default().fg(Color::DarkGray), nc),
148 )));
149
150 let para = Paragraph::new(lines).block(
151 Block::default()
152 .borders(Borders::ALL)
153 .title(" Progress ")
154 .style(crate::ui::s(Style::default().bg(Color::Black), nc)),
155 );
156 frame.render_widget(para, overlay);
157}
158
159fn progress_bar(pct: usize) -> String {
160 let filled = pct / 10; let empty = 10 - filled;
162 format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
163}
164
165fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
166 let vert = Layout::default()
167 .direction(Direction::Vertical)
168 .constraints([
169 Constraint::Percentage((100 - percent_y) / 2),
170 Constraint::Percentage(percent_y),
171 Constraint::Percentage((100 - percent_y) / 2),
172 ])
173 .split(r);
174 Layout::default()
175 .direction(Direction::Horizontal)
176 .constraints([
177 Constraint::Percentage((100 - percent_x) / 2),
178 Constraint::Percentage(percent_x),
179 Constraint::Percentage((100 - percent_x) / 2),
180 ])
181 .split(vert[1])[1]
182}