Skip to main content

debtmap/tui/
renderer.rs

1//! Core rendering logic for TUI components.
2
3use ratatui::{
4    Frame,
5    layout::{Constraint, Direction, Layout},
6    text::{Line, Span},
7    widgets::Paragraph,
8};
9
10use super::app::{App, StageStatus, SubTask};
11use super::layout::calculate_layout;
12use super::theme::Theme;
13
14/// Render the full TUI interface
15pub fn render_ui(frame: &mut Frame, app: &App) {
16    let theme = Theme::default_theme();
17    let chunks = calculate_layout(frame.area());
18
19    render_header(frame, app, &theme, chunks[0]);
20    render_pipeline(frame, app, &theme, chunks[1]);
21    render_footer(frame, app, &theme, chunks[2]);
22}
23
24/// Render compact view (no sub-tasks)
25pub fn render_compact(frame: &mut Frame, app: &App) {
26    let theme = Theme::default_theme();
27    let chunks = calculate_layout(frame.area());
28
29    render_header(frame, app, &theme, chunks[0]);
30    render_pipeline_compact(frame, app, &theme, chunks[1]);
31    render_footer(frame, app, &theme, chunks[2]);
32}
33
34/// Render minimal view (just progress bar)
35pub fn render_minimal(frame: &mut Frame, app: &App) {
36    let theme = Theme::default_theme();
37    let area = frame.area();
38
39    // Simple centered progress bar
40    let chunks = Layout::default()
41        .direction(Direction::Vertical)
42        .margin(1)
43        .constraints([Constraint::Percentage(50), Constraint::Length(3)])
44        .split(area);
45
46    let progress_text = format!(
47        "{} stage {}/{} - {:.1}s",
48        render_progress_bar(app.overall_progress, 30),
49        app.current_stage + 1,
50        app.stages.len(),
51        app.elapsed_time.as_secs_f64()
52    );
53
54    frame.render_widget(
55        Paragraph::new(progress_text).style(theme.progress_bar_style()),
56        chunks[1],
57    );
58}
59
60/// Render header section (title + progress bar)
61fn render_header(frame: &mut Frame, app: &App, theme: &Theme, area: ratatui::layout::Rect) {
62    let header_chunks = Layout::default()
63        .direction(Direction::Vertical)
64        .constraints([
65            Constraint::Length(1), // Title line
66            Constraint::Length(1), // Empty
67            Constraint::Length(1), // Progress bar
68            Constraint::Length(1), // Stage counter
69        ])
70        .split(area);
71
72    // Title with elapsed time
73    let title_line = Line::from(vec![
74        Span::raw("debtmap"),
75        Span::raw("  "),
76        Span::styled(
77            format!("{:.1}s", app.elapsed_time.as_secs_f64()),
78            theme.time_style(),
79        ),
80    ]);
81    frame.render_widget(Paragraph::new(title_line), header_chunks[0]);
82
83    // Progress bar
84    let progress_text = format!(
85        "{} {}%",
86        render_progress_bar(app.overall_progress, area.width.saturating_sub(6) as usize),
87        (app.overall_progress * 100.0) as u32
88    );
89    frame.render_widget(
90        Paragraph::new(progress_text).style(theme.progress_bar_style()),
91        header_chunks[2],
92    );
93
94    // Stage counter
95    let stage_info = format!("stage {}/{}", app.current_stage + 1, app.stages.len());
96    frame.render_widget(
97        Paragraph::new(stage_info).style(theme.metric_style()),
98        header_chunks[3],
99    );
100}
101
102/// Render pipeline stages (full view with sub-tasks)
103fn render_pipeline(frame: &mut Frame, app: &App, theme: &Theme, area: ratatui::layout::Rect) {
104    let mut lines = Vec::new();
105
106    for stage in &app.stages {
107        // Add spacing
108        lines.push(Line::from(""));
109
110        // Stage line
111        let (icon, style) = match stage.status {
112            StageStatus::Completed => ("✓", theme.completed_style()),
113            StageStatus::Active => ("▸", theme.active_style()),
114            StageStatus::Pending => ("·", theme.pending_style()),
115        };
116
117        let mut spans = vec![
118            Span::styled(icon, style),
119            Span::raw("  "),
120            Span::styled(
121                &stage.name,
122                theme.stage_name_style(stage.status == StageStatus::Active),
123            ),
124        ];
125
126        // Add metric if present, aligned to match progress bar right edge
127        if let Some(metric) = &stage.metric {
128            // Progress bar ends at width - 5 (accounting for leading space and " XX%" suffix)
129            // Stage line: {icon}  {name}{whitespace}{metric}
130            // Total width should be: 1 + 2 + name_chars + whitespace + metric_chars = width - 5
131            let icon_width = 1; // Display width of Unicode icons (✓, ▸, ·)
132            let spacing_width = 2; // Two spaces after icon
133            let alignment_offset = 5; // Align to progress bar right edge (width - 5)
134
135            let remaining = area.width.saturating_sub(
136                (icon_width
137                    + spacing_width
138                    + stage.name.chars().count()
139                    + metric.chars().count()
140                    + alignment_offset) as u16,
141            );
142            spans.push(Span::raw(" ".repeat(remaining as usize)));
143            spans.push(Span::styled(metric, theme.metric_style()));
144        }
145
146        lines.push(Line::from(spans));
147
148        // Sub-tasks (only for active stage)
149        if stage.status == StageStatus::Active && !stage.sub_tasks.is_empty() {
150            for subtask in &stage.sub_tasks {
151                lines.push(render_subtask_line(
152                    subtask,
153                    app.animation_frame,
154                    theme,
155                    area.width,
156                ));
157            }
158        }
159    }
160
161    frame.render_widget(Paragraph::new(lines), area);
162}
163
164/// Render pipeline stages (compact view without sub-tasks)
165fn render_pipeline_compact(
166    frame: &mut Frame,
167    app: &App,
168    theme: &Theme,
169    area: ratatui::layout::Rect,
170) {
171    let mut lines = Vec::new();
172
173    for stage in &app.stages {
174        let (icon, style) = match stage.status {
175            StageStatus::Completed => ("✓", theme.completed_style()),
176            StageStatus::Active => ("▸", theme.active_style()),
177            StageStatus::Pending => ("·", theme.pending_style()),
178        };
179
180        let mut spans = vec![
181            Span::styled(icon, style),
182            Span::raw("  "),
183            Span::styled(
184                &stage.name,
185                theme.stage_name_style(stage.status == StageStatus::Active),
186            ),
187        ];
188
189        if let Some(metric) = &stage.metric {
190            spans.push(Span::raw("  "));
191            spans.push(Span::styled(metric, theme.metric_style()));
192        }
193
194        lines.push(Line::from(spans));
195    }
196
197    frame.render_widget(Paragraph::new(lines), area);
198}
199
200/// Render a sub-task line with right-aligned metrics.
201///
202/// Uses whitespace-based layout following the "futuristic zen minimalism" design principle:
203/// - Sub-task name on the left with 4-space indentation
204/// - Metric (progress count or "done") right-aligned with pure whitespace gap
205/// - No decorative elements (dots, progress bars, animations)
206///
207/// Three display modes based on status:
208/// - Completed: name + whitespace + "done" (in completed style)
209/// - Active with progress: name + whitespace + "125/450" (in metric style)
210/// - Pending or no progress: name only
211fn render_subtask_line(
212    subtask: &SubTask,
213    _frame: usize, // No longer used - sub-task animations removed for clarity
214    theme: &Theme,
215    width: u16,
216) -> Line<'static> {
217    const INDENT: &str = "    ";
218    let name_with_indent = format!("{}{}", INDENT, subtask.name);
219
220    match subtask.status {
221        StageStatus::Completed => {
222            // Right-align "done" with whitespace, matching progress bar right edge
223            let metric = "done";
224            let alignment_offset = 5; // Align to progress bar right edge (width - 5)
225            let spacing_needed = width.saturating_sub(
226                (name_with_indent.chars().count() + metric.len() + alignment_offset) as u16,
227            ) as usize;
228
229            Line::from(vec![
230                Span::raw(name_with_indent),
231                Span::raw(" ".repeat(spacing_needed)),
232                Span::styled(metric, theme.completed_style()),
233            ])
234        }
235        StageStatus::Active => {
236            if let Some((current, total)) = subtask.progress {
237                // Right-align numeric count with whitespace, matching progress bar right edge
238                let metric = format!("{}/{}", current, total);
239                let alignment_offset = 5; // Align to progress bar right edge (width - 5)
240                let spacing_needed = width.saturating_sub(
241                    (name_with_indent.chars().count() + metric.chars().count() + alignment_offset)
242                        as u16,
243                ) as usize;
244
245                Line::from(vec![
246                    Span::raw(name_with_indent),
247                    Span::raw(" ".repeat(spacing_needed)),
248                    Span::styled(metric, theme.metric_style()),
249                ])
250            } else {
251                // No progress data - show name only
252                Line::from(Span::raw(name_with_indent))
253            }
254        }
255        StageStatus::Pending => {
256            // Show name only - no trailing indicators
257            Line::from(Span::raw(name_with_indent))
258        }
259    }
260}
261
262/// Render footer with statistics
263fn render_footer(frame: &mut Frame, app: &App, theme: &Theme, area: ratatui::layout::Rect) {
264    let stats = format!(
265        "functions {}  │  debt {}  │  coverage {:.1}%",
266        format_number(app.functions_count),
267        app.debt_count,
268        app.coverage_percent
269    );
270    frame.render_widget(Paragraph::new(stats).style(theme.metric_style()), area);
271}
272
273/// Render a progress bar with gradient characters
274fn render_progress_bar(progress: f64, width: usize) -> String {
275    let filled = (progress * width as f64) as usize;
276    let empty = width.saturating_sub(filled);
277
278    format!("{}{}", "▓".repeat(filled), "░".repeat(empty))
279}
280
281/// Format large numbers with thousand separators
282fn format_number(n: usize) -> String {
283    n.to_string()
284        .as_bytes()
285        .rchunks(3)
286        .rev()
287        .map(std::str::from_utf8)
288        .collect::<Result<Vec<&str>, _>>()
289        .unwrap()
290        .join(",")
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn test_progress_bar_rendering() {
299        let bar = render_progress_bar(0.5, 20);
300        assert_eq!(bar.len(), 20 * "▓".len());
301        assert!(bar.contains("▓"));
302        assert!(bar.contains("░"));
303    }
304
305    #[test]
306    fn test_progress_bar_bounds() {
307        let bar_empty = render_progress_bar(0.0, 10);
308        assert_eq!(bar_empty, "░░░░░░░░░░");
309
310        let bar_full = render_progress_bar(1.0, 10);
311        assert_eq!(bar_full, "▓▓▓▓▓▓▓▓▓▓");
312    }
313
314    #[test]
315    fn test_format_number() {
316        assert_eq!(format_number(0), "0");
317        assert_eq!(format_number(123), "123");
318        assert_eq!(format_number(1234), "1,234");
319        assert_eq!(format_number(1234567), "1,234,567");
320    }
321}