Skip to main content

semantic_diff/ui/
preview_view.rs

1//! Preview pane renderer: renders parsed markdown in the diff area.
2//!
3//! Mermaid diagrams rendered as inline images by writing terminal-specific
4//! escape sequences (iTerm2 OSC 1337, Kitty graphics protocol) directly
5//! to stdout after ratatui's buffer flush. This bypasses ratatui's buffer
6//! system which cannot represent image protocol data.
7
8use crate::app::App;
9use crate::preview::markdown::PreviewBlock;
10use crate::preview::mermaid::{ImageProtocol, ImageSupport, MermaidRenderState};
11use ratatui::layout::Rect;
12use ratatui::style::{Color, Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::Paragraph;
15use ratatui::Frame;
16use std::io::Write;
17
18/// Info about where an image should be rendered (collected during layout,
19/// written to stdout after ratatui flushes).
20pub struct PendingImage {
21    pub path: std::path::PathBuf,
22    pub x: u16,
23    pub y: u16,
24    pub width: u16,
25    pub height: u16,
26}
27
28/// Render markdown preview. Returns pending images that need to be written
29/// to stdout AFTER terminal.draw() completes.
30pub fn render_preview(app: &App, frame: &mut Frame, area: Rect) -> Vec<PendingImage> {
31    let mut pending_images = Vec::new();
32
33    let file_content = match get_current_md_content(app) {
34        Some(content) => content,
35        None => {
36            let msg = Paragraph::new(Line::from(Span::styled(
37                " Preview only available for .md files",
38                Style::default().fg(Color::DarkGray),
39            )));
40            frame.render_widget(msg, area);
41            return pending_images;
42        }
43    };
44
45    let blocks = crate::preview::markdown::parse_markdown(&file_content);
46    let can_render_images = matches!(app.image_support, ImageSupport::Supported(_));
47
48    // Build segments
49    let mut segments: Vec<Segment> = Vec::new();
50
51    // Title bar
52    if let Some(path) = get_current_md_path(app) {
53        segments.push(Segment::Text(vec![
54            Line::from(vec![
55                Span::styled(
56                    " Preview: ",
57                    Style::default()
58                        .fg(Color::Black)
59                        .bg(Color::Cyan)
60                        .add_modifier(Modifier::BOLD),
61                ),
62                Span::styled(
63                    format!("{path} "),
64                    Style::default()
65                        .fg(Color::White)
66                        .bg(Color::Cyan)
67                        .add_modifier(Modifier::BOLD),
68                ),
69            ]),
70            Line::raw(""),
71        ]));
72    }
73
74    for block in &blocks {
75        match block {
76            PreviewBlock::Text(lines) => {
77                segments.push(Segment::Text(lines.clone()));
78            }
79            PreviewBlock::Mermaid(mermaid_block) => {
80                if can_render_images {
81                    build_mermaid_segment(app, mermaid_block, &mut segments);
82                } else {
83                    let mut lines = Vec::new();
84                    render_mermaid_source(mermaid_block, &mut lines);
85                    lines.push(Line::raw(""));
86                    segments.push(Segment::Text(lines));
87                }
88            }
89        }
90    }
91
92    // Render segments vertically with scroll
93    let scroll = app.ui_state.preview_scroll as u16;
94    let mut y_content: u16 = 0;
95    let mut y_screen: u16 = 0;
96
97    for segment in &segments {
98        let seg_h = segment.height(area.width);
99        let seg_end = y_content + seg_h;
100
101        if seg_end <= scroll {
102            y_content = seg_end;
103            continue;
104        }
105        if y_screen >= area.height {
106            break;
107        }
108
109        let clip_top = scroll.saturating_sub(y_content);
110        let visible_h = seg_h.saturating_sub(clip_top).min(area.height - y_screen);
111        if visible_h == 0 {
112            y_content = seg_end;
113            continue;
114        }
115
116        let seg_area = Rect::new(area.x, area.y + y_screen, area.width, visible_h);
117
118        match segment {
119            Segment::Text(lines) => {
120                let para = Paragraph::new(lines.clone()).scroll((clip_top, 0));
121                frame.render_widget(para, seg_area);
122            }
123            Segment::Image { ref path } => {
124                // Reserve blank space in ratatui's buffer
125                let blank_lines: Vec<Line> = (0..visible_h).map(|_| Line::raw("")).collect();
126                frame.render_widget(Paragraph::new(blank_lines), seg_area);
127
128                // Queue image for rendering after ratatui flushes
129                if clip_top == 0 {
130                    pending_images.push(PendingImage {
131                        path: path.clone(),
132                        x: seg_area.x,
133                        y: seg_area.y,
134                        width: seg_area.width,
135                        height: seg_area.height,
136                    });
137                }
138            }
139        }
140
141        y_screen += visible_h;
142        y_content = seg_end;
143    }
144
145    pending_images
146}
147
148/// Write pending images to stdout using the appropriate terminal protocol.
149/// Call this AFTER terminal.draw() so ratatui's buffer has already been flushed.
150/// `had_images_last_frame`: if true and `images` is empty, clears stale images.
151pub fn flush_images(
152    images: &[PendingImage],
153    protocol: ImageProtocol,
154) {
155    // If we had images last frame but not this frame, force full redraw
156    // to clear the stale image data that ratatui doesn't know about.
157    if images.is_empty() {
158        return;
159    }
160
161    let mut stdout = std::io::stdout();
162    for img in images {
163        let png_data = match std::fs::read(&img.path) {
164            Ok(data) => data,
165            Err(_) => continue,
166        };
167
168        match protocol {
169            ImageProtocol::Iterm2 => {
170                write_iterm2_image(&mut stdout, &png_data, img);
171            }
172            ImageProtocol::Kitty => {
173                write_kitty_image(&mut stdout, &png_data, img);
174            }
175        }
176    }
177    let _ = stdout.flush();
178}
179
180/// Clear any stale inline images by forcing a full terminal redraw.
181/// Call when previous frame had images but current frame does not.
182pub fn clear_stale_images(
183    protocol: ImageProtocol,
184    terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
185) {
186    // For Kitty: explicitly delete all placement images
187    if protocol == ImageProtocol::Kitty {
188        let mut stdout = std::io::stdout();
189        let _ = write!(stdout, "\x1b_Ga=d;\x1b\\");
190        let _ = stdout.flush();
191    }
192    // Force ratatui to redraw every cell on the next frame,
193    // which overwrites any leftover image pixels.
194    let _ = terminal.clear();
195}
196
197/// Write an image using iTerm2's inline image protocol (OSC 1337).
198/// Uses cell-based dimensions so the image scales to fit the diff pane width.
199fn write_iterm2_image(stdout: &mut impl Write, png_data: &[u8], img: &PendingImage) {
200    use base64::Engine;
201    let b64 = base64::engine::general_purpose::STANDARD.encode(png_data);
202
203    // Move cursor to image position
204    let _ = write!(stdout, "\x1b[{};{}H", img.y + 1, img.x + 1);
205    // width/height in cell units — iTerm2 auto-scales the image to fit
206    let _ = write!(
207        stdout,
208        "\x1b]1337;File=inline=1;width={w};height={h};preserveAspectRatio=1:{b64}\x07",
209        w = img.width,
210        h = img.height,
211    );
212}
213
214/// Write an image using Kitty's graphics protocol.
215/// c= columns, r= rows for cell-based sizing.
216fn write_kitty_image(stdout: &mut impl Write, png_data: &[u8], img: &PendingImage) {
217    use base64::Engine;
218    let b64 = base64::engine::general_purpose::STANDARD.encode(png_data);
219
220    // Move cursor to image position
221    let _ = write!(stdout, "\x1b[{};{}H", img.y + 1, img.x + 1);
222
223    // Kitty: send PNG data in chunks (max 4096 per chunk)
224    let chunks: Vec<&str> = b64
225        .as_bytes()
226        .chunks(4096)
227        .map(|c| std::str::from_utf8(c).unwrap_or(""))
228        .collect();
229    for (i, chunk) in chunks.iter().enumerate() {
230        let more = if i < chunks.len() - 1 { 1 } else { 0 };
231        if i == 0 {
232            let _ = write!(
233                stdout,
234                "\x1b_Ga=T,f=100,t=d,c={},r={},m={more};{chunk}\x1b\\",
235                img.width, img.height
236            );
237        } else {
238            let _ = write!(stdout, "\x1b_Gm={more};{chunk}\x1b\\");
239        }
240    }
241}
242
243enum Segment {
244    Text(Vec<Line<'static>>),
245    Image { path: std::path::PathBuf },
246}
247
248impl Segment {
249    fn height(&self, pane_width: u16) -> u16 {
250        match self {
251            Segment::Text(lines) => lines.len() as u16,
252            Segment::Image { ref path } => estimate_image_height(path, pane_width),
253        }
254    }
255}
256
257fn build_mermaid_segment(
258    app: &App,
259    block: &crate::preview::mermaid::MermaidBlock,
260    segments: &mut Vec<Segment>,
261) {
262    let state = app
263        .mermaid_cache
264        .as_ref()
265        .map(|c| c.get_state_blocking(&block.hash))
266        .unwrap_or(MermaidRenderState::Pending);
267
268    match state {
269        MermaidRenderState::Ready(path) => {
270            segments.push(Segment::Image { path });
271            segments.push(Segment::Text(vec![Line::raw("")]));
272        }
273        MermaidRenderState::Rendering => {
274            segments.push(Segment::Text(vec![
275                Line::from(Span::styled(
276                    "  [Rendering diagram...]",
277                    Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
278                )),
279                Line::raw(""),
280            ]));
281        }
282        MermaidRenderState::Pending => {
283            if let Some(ref cache) = app.mermaid_cache {
284                if let Some(ref tx) = app.event_tx {
285                    cache.render_async(block.clone(), tx.clone());
286                }
287            }
288            segments.push(Segment::Text(vec![
289                Line::from(Span::styled(
290                    "  [Rendering diagram...]",
291                    Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
292                )),
293                Line::raw(""),
294            ]));
295        }
296        MermaidRenderState::Failed(err) => {
297            let mut lines = vec![
298                Line::from(Span::styled(
299                    format!("  [Diagram error: {err}]"),
300                    Style::default().fg(Color::Red).add_modifier(Modifier::DIM),
301                )),
302                Line::raw(""),
303            ];
304            render_mermaid_source(block, &mut lines);
305            lines.push(Line::raw(""));
306            segments.push(Segment::Text(lines));
307        }
308    }
309}
310
311/// Estimate terminal rows needed for the image, preserving aspect ratio
312/// relative to the pane width. Assumes ~2:1 char aspect ratio (chars are
313/// roughly twice as tall as they are wide).
314fn estimate_image_height(path: &std::path::Path, pane_width: u16) -> u16 {
315    if let Ok(img) = image::open(path) {
316        let (img_w, img_h) = (img.width() as f64, img.height() as f64);
317        if img_w > 0.0 {
318            // Image will be scaled to fill pane_width columns.
319            // Terminal chars are ~2x taller than wide, so divide by 2 for rows.
320            let aspect = img_h / img_w;
321            let rows = (pane_width as f64 * aspect / 2.0).ceil() as u16;
322            rows.clamp(3, 50)
323        } else {
324            10
325        }
326    } else {
327        10
328    }
329}
330
331fn render_mermaid_source(
332    block: &crate::preview::mermaid::MermaidBlock,
333    lines: &mut Vec<Line<'static>>,
334) {
335    lines.push(Line::from(Span::styled(
336        "  ```mermaid".to_string(),
337        Style::default().fg(Color::DarkGray),
338    )));
339    for src_line in block.source.lines() {
340        lines.push(Line::from(Span::styled(
341            format!("  {src_line}"),
342            Style::default().fg(Color::Cyan),
343        )));
344    }
345    lines.push(Line::from(Span::styled(
346        "  ```".to_string(),
347        Style::default().fg(Color::DarkGray),
348    )));
349}
350
351fn get_current_md_content(app: &App) -> Option<String> {
352    let path = get_current_md_path(app)?;
353    std::fs::read_to_string(&path).ok()
354}
355
356fn get_current_md_path(app: &App) -> Option<String> {
357    let items = app.visible_items();
358    let selected = items.get(app.ui_state.selected_index)?;
359    let file_idx = match selected {
360        crate::app::VisibleItem::FileHeader { file_idx } => *file_idx,
361        crate::app::VisibleItem::HunkHeader { file_idx, .. } => *file_idx,
362        crate::app::VisibleItem::DiffLine { file_idx, .. } => *file_idx,
363    };
364    let file = app.diff_data.files.get(file_idx)?;
365    let path = file.target_file.trim_start_matches("b/");
366    if path.ends_with(".md") || path.ends_with(".markdown") || path.ends_with(".mdown") {
367        Some(path.to_string())
368    } else {
369        None
370    }
371}
372
373pub fn is_current_file_markdown(app: &App) -> bool {
374    get_current_md_path(app).is_some()
375}