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, Wrap};
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, area.width, &app.theme);
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())
121                    .wrap(Wrap { trim: false })
122                    .scroll((clip_top, 0));
123                frame.render_widget(para, seg_area);
124            }
125            Segment::Image { ref path } => {
126                // Reserve blank space in ratatui's buffer
127                let blank_lines: Vec<Line> = (0..visible_h).map(|_| Line::raw("")).collect();
128                frame.render_widget(Paragraph::new(blank_lines), seg_area);
129
130                // Queue image for rendering after ratatui flushes.
131                // Use capped dimensions so small diagrams don't stretch.
132                if clip_top == 0 {
133                    let (img_cols, _) = estimate_image_size(path, seg_area.width);
134                    pending_images.push(PendingImage {
135                        path: path.clone(),
136                        x: seg_area.x,
137                        y: seg_area.y,
138                        width: img_cols,
139                        height: seg_area.height,
140                    });
141                }
142            }
143        }
144
145        y_screen += visible_h;
146        y_content = seg_end;
147    }
148
149    pending_images
150}
151
152/// Write pending images to stdout using the appropriate terminal protocol.
153/// Call this AFTER terminal.draw() so ratatui's buffer has already been flushed.
154/// `had_images_last_frame`: if true and `images` is empty, clears stale images.
155pub fn flush_images(
156    images: &[PendingImage],
157    protocol: ImageProtocol,
158) {
159    // If we had images last frame but not this frame, force full redraw
160    // to clear the stale image data that ratatui doesn't know about.
161    if images.is_empty() {
162        return;
163    }
164
165    let mut stdout = std::io::stdout();
166    for img in images {
167        let png_data = match std::fs::read(&img.path) {
168            Ok(data) => data,
169            Err(_) => continue,
170        };
171
172        match protocol {
173            ImageProtocol::Iterm2 => {
174                write_iterm2_image(&mut stdout, &png_data, img);
175            }
176            ImageProtocol::Kitty => {
177                write_kitty_image(&mut stdout, &png_data, img);
178            }
179        }
180    }
181    let _ = stdout.flush();
182}
183
184/// Clear any stale inline images by overwriting all cells.
185/// Call when previous frame had images but current frame does not.
186///
187/// `terminal.clear()` alone is insufficient: it resets ratatui's buffer and
188/// queues `\x1b[2J`, but ratatui's diff algorithm skips cells that are empty
189/// in both the old and new buffers. Image pixels in those cells persist.
190/// We explicitly write spaces to every cell to guarantee overwrite.
191pub fn clear_stale_images(
192    protocol: ImageProtocol,
193    terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
194) {
195    let mut stdout = std::io::stdout();
196
197    // For Kitty: explicitly delete all image placements
198    if protocol == ImageProtocol::Kitty {
199        let _ = write!(stdout, "\x1b_Ga=d,d=a;\x1b\\");
200    }
201
202    // Write spaces to every cell to overwrite lingering image pixels.
203    // Inline images (iTerm2 OSC 1337, Kitty) bypass ratatui's buffer,
204    // so we must physically overwrite the cells they occupied.
205    if let Ok(size) = terminal.size() {
206        let blank_line = " ".repeat(size.width as usize);
207        for row in 0..size.height {
208            let _ = write!(stdout, "\x1b[{};1H{blank_line}", row + 1);
209        }
210    }
211    let _ = stdout.flush();
212
213    // Reset ratatui's buffer state so the next draw rewrites all content.
214    let _ = terminal.clear();
215}
216
217/// Write an image using iTerm2's inline image protocol (OSC 1337).
218/// Uses cell-based dimensions so the image scales to fit the diff pane width.
219fn write_iterm2_image(stdout: &mut impl Write, png_data: &[u8], img: &PendingImage) {
220    use base64::Engine;
221    let b64 = base64::engine::general_purpose::STANDARD.encode(png_data);
222
223    // Move cursor to image position
224    let _ = write!(stdout, "\x1b[{};{}H", img.y + 1, img.x + 1);
225    // width/height in cell units — iTerm2 auto-scales the image to fit
226    let _ = write!(
227        stdout,
228        "\x1b]1337;File=inline=1;width={w};height={h};preserveAspectRatio=1:{b64}\x07",
229        w = img.width,
230        h = img.height,
231    );
232}
233
234/// Write an image using Kitty's graphics protocol.
235/// c= columns, r= rows for cell-based sizing.
236fn write_kitty_image(stdout: &mut impl Write, png_data: &[u8], img: &PendingImage) {
237    use base64::Engine;
238    let b64 = base64::engine::general_purpose::STANDARD.encode(png_data);
239
240    // Move cursor to image position
241    let _ = write!(stdout, "\x1b[{};{}H", img.y + 1, img.x + 1);
242
243    // Kitty: send PNG data in chunks (max 4096 per chunk)
244    let chunks: Vec<&str> = b64
245        .as_bytes()
246        .chunks(4096)
247        .map(|c| std::str::from_utf8(c).unwrap_or(""))
248        .collect();
249    for (i, chunk) in chunks.iter().enumerate() {
250        let more = if i < chunks.len() - 1 { 1 } else { 0 };
251        if i == 0 {
252            let _ = write!(
253                stdout,
254                "\x1b_Ga=T,f=100,t=d,c={},r={},m={more};{chunk}\x1b\\",
255                img.width, img.height
256            );
257        } else {
258            let _ = write!(stdout, "\x1b_Gm={more};{chunk}\x1b\\");
259        }
260    }
261}
262
263enum Segment {
264    Text(Vec<Line<'static>>),
265    Image { path: std::path::PathBuf },
266}
267
268impl Segment {
269    fn height(&self, pane_width: u16) -> u16 {
270        match self {
271            Segment::Text(lines) => {
272                if pane_width == 0 {
273                    return lines.len() as u16;
274                }
275                let w = pane_width as usize;
276                lines
277                    .iter()
278                    .map(|line| {
279                        let char_width: usize =
280                            line.spans.iter().map(|s| s.content.chars().count()).sum();
281                        if char_width == 0 {
282                            1
283                        } else {
284                            char_width.div_ceil(w)
285                        }
286                    })
287                    .sum::<usize>() as u16
288            }
289            Segment::Image { ref path } => estimate_image_height(path, pane_width),
290        }
291    }
292}
293
294fn build_mermaid_segment(
295    app: &App,
296    block: &crate::preview::mermaid::MermaidBlock,
297    segments: &mut Vec<Segment>,
298) {
299    let state = app
300        .mermaid_cache
301        .as_ref()
302        .map(|c| c.get_state_blocking(&block.hash))
303        .unwrap_or(MermaidRenderState::Pending);
304
305    match state {
306        MermaidRenderState::Ready(path) => {
307            segments.push(Segment::Image { path });
308            segments.push(Segment::Text(vec![Line::raw("")]));
309        }
310        MermaidRenderState::Rendering => {
311            segments.push(Segment::Text(vec![
312                Line::from(Span::styled(
313                    "  [Rendering diagram...]",
314                    Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
315                )),
316                Line::raw(""),
317            ]));
318        }
319        MermaidRenderState::Pending => {
320            if let Some(ref cache) = app.mermaid_cache {
321                if let Some(ref tx) = app.event_tx {
322                    cache.render_async(block.clone(), tx.clone());
323                }
324            }
325            segments.push(Segment::Text(vec![
326                Line::from(Span::styled(
327                    "  [Rendering diagram...]",
328                    Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
329                )),
330                Line::raw(""),
331            ]));
332        }
333        MermaidRenderState::Failed(err) => {
334            let mut lines = vec![
335                Line::from(Span::styled(
336                    format!("  [Diagram error: {err}]"),
337                    Style::default().fg(Color::Red).add_modifier(Modifier::DIM),
338                )),
339                Line::raw(""),
340            ];
341            render_mermaid_source(block, &mut lines);
342            lines.push(Line::raw(""));
343            segments.push(Segment::Text(lines));
344        }
345    }
346}
347
348/// Estimate the display size (columns, rows) for a mermaid image.
349/// Caps the width based on the image's native pixel width so small diagrams
350/// don't stretch to fill the entire pane. Preserves aspect ratio.
351/// Assumes ~8 pixels per terminal column and ~16 pixels per terminal row
352/// (standard monospace font at common sizes).
353fn estimate_image_size(path: &std::path::Path, pane_width: u16) -> (u16, u16) {
354    if let Ok(img) = image::open(path) {
355        let (img_w, img_h) = (img.width() as f64, img.height() as f64);
356        if img_w > 0.0 {
357            // Estimate how many columns the image "naturally" needs.
358            // ~8px per column is typical for monospace fonts.
359            let natural_cols = (img_w / 8.0).ceil() as u16;
360            // Don't exceed pane width, but also don't stretch small images
361            let display_cols = natural_cols.min(pane_width);
362            // Compute height preserving aspect ratio.
363            // Terminal chars are ~2x taller than wide, so divide by 2.
364            let aspect = img_h / img_w;
365            let rows = (display_cols as f64 * aspect / 2.0).ceil() as u16;
366            (display_cols.max(10), rows.clamp(3, 50))
367        } else {
368            (pane_width.min(60), 10)
369        }
370    } else {
371        (pane_width.min(60), 10)
372    }
373}
374
375/// Estimate terminal rows needed for the image (used for layout sizing).
376fn estimate_image_height(path: &std::path::Path, pane_width: u16) -> u16 {
377    estimate_image_size(path, pane_width).1
378}
379
380fn render_mermaid_source(
381    block: &crate::preview::mermaid::MermaidBlock,
382    lines: &mut Vec<Line<'static>>,
383) {
384    lines.push(Line::from(Span::styled(
385        "  ```mermaid".to_string(),
386        Style::default().fg(Color::DarkGray),
387    )));
388    for src_line in block.source.lines() {
389        lines.push(Line::from(Span::styled(
390            format!("  {src_line}"),
391            Style::default().fg(Color::Cyan),
392        )));
393    }
394    lines.push(Line::from(Span::styled(
395        "  ```".to_string(),
396        Style::default().fg(Color::DarkGray),
397    )));
398}
399
400fn get_current_md_content(app: &App) -> Option<String> {
401    let path = get_current_md_path(app)?;
402    std::fs::read_to_string(&path).ok()
403}
404
405fn get_current_md_path(app: &App) -> Option<String> {
406    let items = app.visible_items();
407    let selected = items.get(app.ui_state.selected_index)?;
408    let file_idx = match selected {
409        crate::app::VisibleItem::FileHeader { file_idx } => *file_idx,
410        crate::app::VisibleItem::HunkHeader { file_idx, .. } => *file_idx,
411        crate::app::VisibleItem::DiffLine { file_idx, .. } => *file_idx,
412    };
413    let file = app.diff_data.files.get(file_idx)?;
414    let path = file.target_file.trim_start_matches("b/");
415    if path.ends_with(".md") || path.ends_with(".markdown") || path.ends_with(".mdown") {
416        Some(path.to_string())
417    } else {
418        None
419    }
420}
421
422pub fn is_current_file_markdown(app: &App) -> bool {
423    get_current_md_path(app).is_some()
424}