Skip to main content

semantic_diff/ui/
mod.rs

1pub mod diff_view;
2pub mod file_tree;
3pub mod preview_view;
4pub mod summary;
5
6use crate::app::{App, InputMode};
7use crate::theme::Theme;
8use ratatui::layout::{Constraint, Layout, Rect};
9use ratatui::style::{Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
12use ratatui::Frame;
13
14/// Draw the entire UI. Returns pending images that must be flushed after
15/// terminal.draw() completes (image protocols bypass ratatui's buffer).
16pub fn draw(app: &App, frame: &mut Frame) -> Vec<preview_view::PendingImage> {
17    let area = frame.area();
18    let mut pending_images = Vec::new();
19
20    // Vertical split: main content area | bottom bar
21    let bottom_height = 1;
22    let vertical =
23        Layout::vertical([Constraint::Min(1), Constraint::Length(bottom_height)]).split(area);
24
25    // Horizontal split: sidebar | diff view
26    let sidebar_width = if area.width < 80 {
27        Constraint::Max(25)
28    } else {
29        Constraint::Max(40)
30    };
31    let horizontal =
32        Layout::horizontal([sidebar_width, Constraint::Min(40)]).split(vertical[0]);
33
34    // Render file tree sidebar in left panel
35    file_tree::render_tree(app, frame, horizontal[0]);
36
37    // Render diff view or preview in right panel
38    if app.preview_mode && preview_view::is_current_file_markdown(app) {
39        pending_images = preview_view::render_preview(app, frame, horizontal[1]);
40    } else {
41        diff_view::render_diff(app, frame, horizontal[1]);
42    }
43
44    // Render bottom bar
45    match app.input_mode {
46        InputMode::Search => render_search_bar(app, frame, vertical[1]),
47        InputMode::Normal | InputMode::Help | InputMode::Settings => {
48            summary::render_summary(app, frame, vertical[1])
49        }
50    }
51
52    // Render help overlay on top if in Help mode
53    if app.input_mode == InputMode::Help {
54        render_help_overlay(frame, area, &app.theme);
55    }
56
57    // Render settings overlay on top if in Settings mode
58    if app.input_mode == InputMode::Settings {
59        render_settings_overlay(frame, area, &app.theme);
60    }
61
62    pending_images
63}
64
65/// Render the help overlay centered on screen.
66fn render_help_overlay(frame: &mut Frame, area: Rect, theme: &Theme) {
67    let shortcuts = vec![
68        ("Navigation", vec![
69            ("j/k, ↑/↓", "Move up/down"),
70            ("g/G", "Jump to top/bottom"),
71            ("Ctrl-d/u", "Half-page down/up"),
72            ("Tab", "Switch sidebar/diff focus"),
73        ]),
74        ("Actions", vec![
75            ("Enter", "Sidebar: select file/group | Diff: toggle collapse"),
76            ("p", "Toggle markdown preview (.md files)"),
77            ("/", "Search files"),
78            ("n/N", "Next/prev search match"),
79            (",", "Settings"),
80            ("Esc", "Clear filter / quit"),
81            ("q", "Quit"),
82        ]),
83    ];
84
85    let mut lines: Vec<Line> = vec![Line::raw("")];
86    for (section, keys) in &shortcuts {
87        lines.push(Line::from(Span::styled(
88            format!("  {section}"),
89            Style::default()
90                .fg(theme.help_section_fg)
91                .add_modifier(Modifier::BOLD),
92        )));
93        for (key, desc) in keys {
94            lines.push(Line::from(vec![
95                Span::styled(
96                    format!("    {key:<14}"),
97                    Style::default()
98                        .fg(theme.help_key_fg)
99                        .add_modifier(Modifier::BOLD),
100                ),
101                Span::styled(*desc, Style::default().fg(theme.help_text_fg)),
102            ]));
103        }
104        lines.push(Line::raw(""));
105    }
106    lines.push(Line::from(Span::styled(
107        "  Press any key to close",
108        Style::default().fg(theme.help_dismiss_fg),
109    )));
110
111    let content_width = lines.iter().map(|l| l.spans.iter().map(|s| s.content.chars().count()).sum::<usize>()).max().unwrap_or(0) as u16;
112    let height = (lines.len() + 2).min(area.height as usize) as u16;
113    let width = (content_width + 4).min(area.width.saturating_sub(4)); // +4 for borders + padding
114    let x = (area.width.saturating_sub(width)) / 2;
115    let y = (area.height.saturating_sub(height)) / 2;
116    let popup_area = Rect::new(x, y, width, height);
117
118    frame.render_widget(Clear, popup_area);
119    let block = Block::bordered()
120        .title(" Shortcuts ")
121        .border_style(Style::default().fg(theme.help_section_fg))
122        .style(Style::default().bg(theme.help_overlay_bg));
123    let paragraph = Paragraph::new(lines).block(block);
124    frame.render_widget(paragraph, popup_area);
125}
126
127/// Render the settings overlay centered on screen.
128fn render_settings_overlay(frame: &mut Frame, area: Rect, theme: &Theme) {
129    let current_mode = if theme.syntect_theme.contains("dark") {
130        "Dark"
131    } else {
132        "Light"
133    };
134
135    let mut lines: Vec<Line> = vec![Line::raw("")];
136
137    // Theme section
138    lines.push(Line::from(Span::styled(
139        "  Theme",
140        Style::default()
141            .fg(theme.help_section_fg)
142            .add_modifier(Modifier::BOLD),
143    )));
144    lines.push(Line::from(vec![
145        Span::styled(
146            format!("    {:<14}", "d"),
147            Style::default()
148                .fg(theme.help_key_fg)
149                .add_modifier(Modifier::BOLD),
150        ),
151        Span::styled(
152            format!("Toggle dark/light mode  [Current: {current_mode}]"),
153            Style::default().fg(theme.help_text_fg),
154        ),
155    ]));
156    lines.push(Line::raw(""));
157
158    lines.push(Line::from(Span::styled(
159        "  Esc to close",
160        Style::default().fg(theme.help_dismiss_fg),
161    )));
162
163    let content_width = lines.iter().map(|l| l.spans.iter().map(|s| s.content.chars().count()).sum::<usize>()).max().unwrap_or(0) as u16;
164    let width = (content_width + 4).min(area.width.saturating_sub(4));
165    let height = (lines.len() + 2).min(area.height as usize) as u16;
166    let x = (area.width.saturating_sub(width)) / 2;
167    let y = (area.height.saturating_sub(height)) / 2;
168    let popup_area = Rect::new(x, y, width, height);
169
170    frame.render_widget(Clear, popup_area);
171    let block = Block::bordered()
172        .title(" Settings ")
173        .border_style(Style::default().fg(theme.help_section_fg))
174        .style(Style::default().bg(theme.help_overlay_bg));
175    let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: false });
176    frame.render_widget(paragraph, popup_area);
177}
178
179/// Render the search input bar at the bottom of the screen.
180fn render_search_bar(app: &App, frame: &mut Frame, area: ratatui::layout::Rect) {
181    let line = Line::from(vec![
182        Span::styled(
183            "/ ",
184            Style::default()
185                .fg(app.theme.help_key_fg)
186                .add_modifier(Modifier::BOLD),
187        ),
188        Span::styled(
189            app.search_query.clone(),
190            Style::default().fg(app.theme.help_text_fg),
191        ),
192        Span::styled(
193            "_",
194            Style::default()
195                .fg(app.theme.help_text_fg)
196                .add_modifier(Modifier::SLOW_BLINK),
197        ),
198    ]);
199    let paragraph = ratatui::widgets::Paragraph::new(line);
200    frame.render_widget(paragraph, area);
201}