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