Skip to main content

semantic_diff/ui/
mod.rs

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