ricecoder_tui/
render.rs

1//! Rendering logic for the TUI
2
3use crate::app::App;
4use crate::diff::{DiffLineType, DiffViewType, DiffWidget};
5use crate::style::Theme;
6use ratatui::prelude::*;
7
8/// Renderer for the TUI
9pub struct Renderer;
10
11impl Renderer {
12    /// Create a new renderer
13    pub fn new() -> Self {
14        Self
15    }
16
17    /// Render the application
18    pub fn render(&self, _app: &App) -> anyhow::Result<()> {
19        // TODO: Implement rendering with ratatui
20        Ok(())
21    }
22
23    /// Render a diff widget in unified view
24    pub fn render_diff_unified(
25        &self,
26        diff: &DiffWidget,
27        _area: Rect,
28        _theme: &Theme,
29    ) -> Vec<Line<'static>> {
30        let mut lines = Vec::new();
31
32        // Add header showing view type and stats
33        let total_lines: usize = diff.hunks.iter().map(|h| h.lines.len()).sum();
34        let added_count = diff
35            .hunks
36            .iter()
37            .flat_map(|h| &h.lines)
38            .filter(|l| l.line_type == DiffLineType::Added)
39            .count();
40        let removed_count = diff
41            .hunks
42            .iter()
43            .flat_map(|h| &h.lines)
44            .filter(|l| l.line_type == DiffLineType::Removed)
45            .count();
46
47        let header = format!(
48            "Unified Diff View | {} lines | +{} -{} | Approved: {}",
49            total_lines,
50            added_count,
51            removed_count,
52            diff.approved_hunks().len()
53        );
54        lines.push(Line::from(header));
55        lines.push(Line::from(""));
56
57        // Render each hunk
58        for (hunk_idx, hunk) in diff.hunks.iter().enumerate() {
59            let is_selected = diff.selected_hunk == Some(hunk_idx);
60            let is_approved = diff.approvals.get(hunk_idx).copied().unwrap_or(false);
61
62            // Hunk header
63            let header_style = if is_selected {
64                Style::default().fg(Color::Cyan).bold()
65            } else if is_approved {
66                Style::default().fg(Color::Green)
67            } else {
68                Style::default().fg(Color::Yellow)
69            };
70
71            let approval_indicator = if is_approved { "✓" } else { " " };
72            let collapse_indicator = if hunk.collapsed { "▶" } else { "▼" };
73
74            let hunk_header = format!(
75                "{} {} {} {}",
76                approval_indicator, collapse_indicator, hunk.header, ""
77            );
78            lines.push(Line::from(Span::styled(hunk_header, header_style)));
79
80            // Render lines if not collapsed
81            if !hunk.collapsed {
82                for line in &hunk.lines {
83                    let (prefix, style) = match line.line_type {
84                        DiffLineType::Added => ("+", Style::default().fg(Color::Green)),
85                        DiffLineType::Removed => ("-", Style::default().fg(Color::Red)),
86                        DiffLineType::Context => (" ", Style::default()),
87                        DiffLineType::Unchanged => (" ", Style::default()),
88                    };
89
90                    let line_num_str = match (line.old_line_num, line.new_line_num) {
91                        (Some(old), Some(new)) => format!("{:4} {:4}", old, new),
92                        (Some(old), None) => format!("{:4}     ", old),
93                        (None, Some(new)) => format!("     {:4}", new),
94                        (None, None) => "          ".to_string(),
95                    };
96
97                    let content = format!("{} {} {}", prefix, line_num_str, line.content);
98                    lines.push(Line::from(Span::styled(content, style)));
99                }
100            }
101
102            lines.push(Line::from(""));
103        }
104
105        lines
106    }
107
108    /// Render a diff widget in side-by-side view
109    pub fn render_diff_side_by_side(
110        &self,
111        diff: &DiffWidget,
112        area: Rect,
113        _theme: &Theme,
114    ) -> Vec<Line<'static>> {
115        let mut lines = Vec::new();
116
117        // Add header
118        let total_lines: usize = diff.hunks.iter().map(|h| h.lines.len()).sum();
119        let added_count = diff
120            .hunks
121            .iter()
122            .flat_map(|h| &h.lines)
123            .filter(|l| l.line_type == DiffLineType::Added)
124            .count();
125        let removed_count = diff
126            .hunks
127            .iter()
128            .flat_map(|h| &h.lines)
129            .filter(|l| l.line_type == DiffLineType::Removed)
130            .count();
131
132        let header = format!(
133            "Side-by-Side Diff View | {} lines | +{} -{} | Approved: {}",
134            total_lines,
135            added_count,
136            removed_count,
137            diff.approved_hunks().len()
138        );
139        lines.push(Line::from(header));
140        lines.push(Line::from(""));
141
142        // Column headers
143        let col_width = (area.width as usize).saturating_sub(20) / 2;
144        let header_left = format!("Original ({:width$})", "", width = col_width);
145        let header_right = format!("Modified ({:width$})", "", width = col_width);
146        lines.push(Line::from(format!("{} | {}", header_left, header_right)));
147        lines.push(Line::from("─".repeat(area.width as usize)));
148
149        // Render each hunk
150        for (hunk_idx, hunk) in diff.hunks.iter().enumerate() {
151            let is_selected = diff.selected_hunk == Some(hunk_idx);
152            let is_approved = diff.approvals.get(hunk_idx).copied().unwrap_or(false);
153
154            // Hunk header
155            let header_style = if is_selected {
156                Style::default().fg(Color::Cyan).bold()
157            } else if is_approved {
158                Style::default().fg(Color::Green)
159            } else {
160                Style::default().fg(Color::Yellow)
161            };
162
163            let approval_indicator = if is_approved { "✓" } else { " " };
164            let collapse_indicator = if hunk.collapsed { "▶" } else { "▼" };
165
166            let hunk_header = format!(
167                "{} {} {}",
168                approval_indicator, collapse_indicator, hunk.header
169            );
170            lines.push(Line::from(Span::styled(hunk_header, header_style)));
171
172            // Render lines if not collapsed
173            if !hunk.collapsed {
174                for line in &hunk.lines {
175                    let (prefix, style) = match line.line_type {
176                        DiffLineType::Added => ("+", Style::default().fg(Color::Green)),
177                        DiffLineType::Removed => ("-", Style::default().fg(Color::Red)),
178                        DiffLineType::Context => (" ", Style::default()),
179                        DiffLineType::Unchanged => (" ", Style::default()),
180                    };
181
182                    let line_num = line.new_line_num.map(|n| n.to_string()).unwrap_or_default();
183                    let content = format!("{} {:4} {}", prefix, line_num, line.content);
184
185                    // For side-by-side, we'd need to track old vs new separately
186                    // For now, show on the right side
187                    let padded = format!("{:<width$} | {}", "", content, width = col_width);
188                    lines.push(Line::from(Span::styled(padded, style)));
189                }
190            }
191
192            lines.push(Line::from(""));
193        }
194
195        lines
196    }
197
198    /// Render diff widget based on view type
199    pub fn render_diff(&self, diff: &DiffWidget, area: Rect, theme: &Theme) -> Vec<Line<'static>> {
200        match diff.view_type {
201            DiffViewType::Unified => self.render_diff_unified(diff, area, theme),
202            DiffViewType::SideBySide => self.render_diff_side_by_side(diff, area, theme),
203        }
204    }
205}
206
207impl Default for Renderer {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::diff::{DiffHunk, DiffLine};
217
218    #[test]
219    fn test_renderer_creation() {
220        let renderer = Renderer::new();
221        let default_renderer = Renderer::default();
222        // Both should be created successfully
223        let _ = renderer;
224        let _ = default_renderer;
225    }
226
227    #[test]
228    fn test_render_diff_unified_empty() {
229        let renderer = Renderer::new();
230        let diff = DiffWidget::new();
231        let theme = Theme::default();
232        let area = Rect {
233            x: 0,
234            y: 0,
235            width: 80,
236            height: 24,
237        };
238
239        let lines = renderer.render_diff_unified(&diff, area, &theme);
240        assert!(!lines.is_empty());
241        // Should have at least header and empty line
242        assert!(lines.len() >= 2);
243    }
244
245    #[test]
246    fn test_render_diff_unified_with_hunks() {
247        let renderer = Renderer::new();
248        let mut diff = DiffWidget::new();
249
250        let mut hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
251        hunk.add_line(
252            DiffLine::new(DiffLineType::Unchanged, "let x = 5;")
253                .with_old_line_num(1)
254                .with_new_line_num(1),
255        );
256        hunk.add_line(DiffLine::new(DiffLineType::Added, "let y = 10;").with_new_line_num(2));
257        hunk.add_line(DiffLine::new(DiffLineType::Removed, "let z = 15;").with_old_line_num(2));
258
259        diff.add_hunk(hunk);
260
261        let theme = Theme::default();
262        let area = Rect {
263            x: 0,
264            y: 0,
265            width: 80,
266            height: 24,
267        };
268
269        let lines = renderer.render_diff_unified(&diff, area, &theme);
270        assert!(!lines.is_empty());
271        // Should have header, hunk header, and lines
272        assert!(lines.len() > 3);
273    }
274
275    #[test]
276    fn test_render_diff_unified_with_collapsed_hunk() {
277        let renderer = Renderer::new();
278        let mut diff = DiffWidget::new();
279
280        let mut hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
281        hunk.add_line(DiffLine::new(DiffLineType::Added, "new line"));
282        hunk.toggle_collapsed();
283
284        diff.add_hunk(hunk);
285
286        let theme = Theme::default();
287        let area = Rect {
288            x: 0,
289            y: 0,
290            width: 80,
291            height: 24,
292        };
293
294        let lines = renderer.render_diff_unified(&diff, area, &theme);
295        assert!(!lines.is_empty());
296        // Collapsed hunk should not show its lines
297        let content = lines.iter().map(|l| l.to_string()).collect::<String>();
298        assert!(!content.contains("new line"));
299    }
300
301    #[test]
302    fn test_render_diff_unified_with_approval() {
303        let renderer = Renderer::new();
304        let mut diff = DiffWidget::new();
305
306        let hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
307        diff.add_hunk(hunk);
308        diff.approve_all();
309
310        let theme = Theme::default();
311        let area = Rect {
312            x: 0,
313            y: 0,
314            width: 80,
315            height: 24,
316        };
317
318        let lines = renderer.render_diff_unified(&diff, area, &theme);
319        assert!(!lines.is_empty());
320        // Should show approval indicator
321        let content = lines.iter().map(|l| l.to_string()).collect::<String>();
322        assert!(content.contains("✓"));
323    }
324
325    #[test]
326    fn test_render_diff_side_by_side_empty() {
327        let renderer = Renderer::new();
328        let diff = DiffWidget::new();
329        let theme = Theme::default();
330        let area = Rect {
331            x: 0,
332            y: 0,
333            width: 160,
334            height: 24,
335        };
336
337        let lines = renderer.render_diff_side_by_side(&diff, area, &theme);
338        assert!(!lines.is_empty());
339        // Should have header and column headers
340        assert!(lines.len() >= 3);
341    }
342
343    #[test]
344    fn test_render_diff_side_by_side_with_hunks() {
345        let renderer = Renderer::new();
346        let mut diff = DiffWidget::new();
347
348        let mut hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
349        hunk.add_line(DiffLine::new(DiffLineType::Added, "new line").with_new_line_num(1));
350        hunk.add_line(DiffLine::new(DiffLineType::Removed, "old line").with_old_line_num(1));
351
352        diff.add_hunk(hunk);
353
354        let theme = Theme::default();
355        let area = Rect {
356            x: 0,
357            y: 0,
358            width: 160,
359            height: 24,
360        };
361
362        let lines = renderer.render_diff_side_by_side(&diff, area, &theme);
363        assert!(!lines.is_empty());
364        // Should have header, column headers, hunk header, and lines
365        assert!(lines.len() > 4);
366    }
367
368    #[test]
369    fn test_render_diff_unified_view_type() {
370        let renderer = Renderer::new();
371        let mut diff = DiffWidget::new();
372
373        let mut hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
374        hunk.add_line(DiffLine::new(DiffLineType::Added, "line"));
375        diff.add_hunk(hunk);
376
377        let theme = Theme::default();
378        let area = Rect {
379            x: 0,
380            y: 0,
381            width: 80,
382            height: 24,
383        };
384
385        assert_eq!(diff.view_type, DiffViewType::Unified);
386        let lines = renderer.render_diff(&diff, area, &theme);
387        assert!(!lines.is_empty());
388    }
389
390    #[test]
391    fn test_render_diff_side_by_side_view_type() {
392        let renderer = Renderer::new();
393        let mut diff = DiffWidget::new();
394
395        let mut hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
396        hunk.add_line(DiffLine::new(DiffLineType::Added, "line"));
397        diff.add_hunk(hunk);
398        diff.toggle_view_type();
399
400        let theme = Theme::default();
401        let area = Rect {
402            x: 0,
403            y: 0,
404            width: 160,
405            height: 24,
406        };
407
408        assert_eq!(diff.view_type, DiffViewType::SideBySide);
409        let lines = renderer.render_diff(&diff, area, &theme);
410        assert!(!lines.is_empty());
411    }
412
413    #[test]
414    fn test_render_diff_with_multiple_hunks() {
415        let renderer = Renderer::new();
416        let mut diff = DiffWidget::new();
417
418        for i in 0..3 {
419            let mut hunk = DiffHunk::new(&format!("@@ -{},{} +{},{} @@", i * 5, 5, i * 5, 5));
420            hunk.add_line(DiffLine::new(DiffLineType::Added, format!("line {}", i)));
421            diff.add_hunk(hunk);
422        }
423
424        let theme = Theme::default();
425        let area = Rect {
426            x: 0,
427            y: 0,
428            width: 80,
429            height: 24,
430        };
431
432        let lines = renderer.render_diff_unified(&diff, area, &theme);
433        assert!(!lines.is_empty());
434        // Should have multiple hunk headers
435        let content = lines.iter().map(|l| l.to_string()).collect::<String>();
436        assert!(content.contains("@@"));
437    }
438
439    #[test]
440    fn test_render_diff_line_numbers() {
441        let renderer = Renderer::new();
442        let mut diff = DiffWidget::new();
443
444        let mut hunk = DiffHunk::new("@@ -10,5 +20,6 @@");
445        hunk.add_line(
446            DiffLine::new(DiffLineType::Unchanged, "code")
447                .with_old_line_num(10)
448                .with_new_line_num(20),
449        );
450        diff.add_hunk(hunk);
451
452        let theme = Theme::default();
453        let area = Rect {
454            x: 0,
455            y: 0,
456            width: 80,
457            height: 24,
458        };
459
460        let lines = renderer.render_diff_unified(&diff, area, &theme);
461        let content = lines.iter().map(|l| l.to_string()).collect::<String>();
462        // Should contain line numbers
463        assert!(content.contains("10") || content.contains("20"));
464    }
465
466    #[test]
467    fn test_render_diff_added_removed_lines() {
468        let renderer = Renderer::new();
469        let mut diff = DiffWidget::new();
470
471        let mut hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
472        hunk.add_line(DiffLine::new(DiffLineType::Added, "added line"));
473        hunk.add_line(DiffLine::new(DiffLineType::Removed, "removed line"));
474        hunk.add_line(DiffLine::new(DiffLineType::Unchanged, "unchanged line"));
475        diff.add_hunk(hunk);
476
477        let theme = Theme::default();
478        let area = Rect {
479            x: 0,
480            y: 0,
481            width: 80,
482            height: 24,
483        };
484
485        let lines = renderer.render_diff_unified(&diff, area, &theme);
486        let content = lines.iter().map(|l| l.to_string()).collect::<String>();
487        assert!(content.contains("added line"));
488        assert!(content.contains("removed line"));
489        assert!(content.contains("unchanged line"));
490    }
491
492    #[test]
493    fn test_render_diff_selected_hunk() {
494        let renderer = Renderer::new();
495        let mut diff = DiffWidget::new();
496
497        let hunk1 = DiffHunk::new("@@ -1,5 +1,6 @@");
498        let hunk2 = DiffHunk::new("@@ -10,5 +11,6 @@");
499        diff.add_hunk(hunk1);
500        diff.add_hunk(hunk2);
501
502        diff.select_next_hunk();
503        assert_eq!(diff.selected_hunk, Some(0));
504
505        let theme = Theme::default();
506        let area = Rect {
507            x: 0,
508            y: 0,
509            width: 80,
510            height: 24,
511        };
512
513        let lines = renderer.render_diff_unified(&diff, area, &theme);
514        assert!(!lines.is_empty());
515    }
516
517    #[test]
518    fn test_render_diff_stats() {
519        let renderer = Renderer::new();
520        let mut diff = DiffWidget::new();
521
522        let mut hunk = DiffHunk::new("@@ -1,5 +1,6 @@");
523        hunk.add_line(DiffLine::new(DiffLineType::Added, "line1"));
524        hunk.add_line(DiffLine::new(DiffLineType::Added, "line2"));
525        hunk.add_line(DiffLine::new(DiffLineType::Removed, "line3"));
526        diff.add_hunk(hunk);
527
528        let theme = Theme::default();
529        let area = Rect {
530            x: 0,
531            y: 0,
532            width: 80,
533            height: 24,
534        };
535
536        let lines = renderer.render_diff_unified(&diff, area, &theme);
537        let content = lines.iter().map(|l| l.to_string()).collect::<String>();
538        // Should show stats: +2 -1
539        assert!(content.contains("+2") || content.contains("-1"));
540    }
541}