1use 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
18pub 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
28pub 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 let mut segments: Vec<Segment> = Vec::new();
50
51 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 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 let blank_lines: Vec<Line> = (0..visible_h).map(|_| Line::raw("")).collect();
128 frame.render_widget(Paragraph::new(blank_lines), seg_area);
129
130 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
150pub fn flush_images(
154 images: &[PendingImage],
155 protocol: ImageProtocol,
156) {
157 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
182pub 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 if protocol == ImageProtocol::Kitty {
197 let _ = write!(stdout, "\x1b_Ga=d,d=a;\x1b\\");
198 }
199
200 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 let _ = terminal.clear();
213}
214
215fn 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 let _ = write!(stdout, "\x1b[{};{}H", img.y + 1, img.x + 1);
223 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
232fn 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 let _ = write!(stdout, "\x1b[{};{}H", img.y + 1, img.x + 1);
240
241 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 = line.spans.iter().map(|s| s.content.len()).sum();
278 if char_width == 0 {
279 1
280 } else {
281 char_width.div_ceil(w)
282 }
283 })
284 .sum::<usize>() as u16
285 }
286 Segment::Image { ref path } => estimate_image_height(path, pane_width),
287 }
288 }
289}
290
291fn build_mermaid_segment(
292 app: &App,
293 block: &crate::preview::mermaid::MermaidBlock,
294 segments: &mut Vec<Segment>,
295) {
296 let state = app
297 .mermaid_cache
298 .as_ref()
299 .map(|c| c.get_state_blocking(&block.hash))
300 .unwrap_or(MermaidRenderState::Pending);
301
302 match state {
303 MermaidRenderState::Ready(path) => {
304 segments.push(Segment::Image { path });
305 segments.push(Segment::Text(vec![Line::raw("")]));
306 }
307 MermaidRenderState::Rendering => {
308 segments.push(Segment::Text(vec![
309 Line::from(Span::styled(
310 " [Rendering diagram...]",
311 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
312 )),
313 Line::raw(""),
314 ]));
315 }
316 MermaidRenderState::Pending => {
317 if let Some(ref cache) = app.mermaid_cache {
318 if let Some(ref tx) = app.event_tx {
319 cache.render_async(block.clone(), tx.clone());
320 }
321 }
322 segments.push(Segment::Text(vec![
323 Line::from(Span::styled(
324 " [Rendering diagram...]",
325 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
326 )),
327 Line::raw(""),
328 ]));
329 }
330 MermaidRenderState::Failed(err) => {
331 let mut lines = vec![
332 Line::from(Span::styled(
333 format!(" [Diagram error: {err}]"),
334 Style::default().fg(Color::Red).add_modifier(Modifier::DIM),
335 )),
336 Line::raw(""),
337 ];
338 render_mermaid_source(block, &mut lines);
339 lines.push(Line::raw(""));
340 segments.push(Segment::Text(lines));
341 }
342 }
343}
344
345fn estimate_image_height(path: &std::path::Path, pane_width: u16) -> u16 {
349 if let Ok(img) = image::open(path) {
350 let (img_w, img_h) = (img.width() as f64, img.height() as f64);
351 if img_w > 0.0 {
352 let aspect = img_h / img_w;
355 let rows = (pane_width as f64 * aspect / 2.0).ceil() as u16;
356 rows.clamp(3, 50)
357 } else {
358 10
359 }
360 } else {
361 10
362 }
363}
364
365fn render_mermaid_source(
366 block: &crate::preview::mermaid::MermaidBlock,
367 lines: &mut Vec<Line<'static>>,
368) {
369 lines.push(Line::from(Span::styled(
370 " ```mermaid".to_string(),
371 Style::default().fg(Color::DarkGray),
372 )));
373 for src_line in block.source.lines() {
374 lines.push(Line::from(Span::styled(
375 format!(" {src_line}"),
376 Style::default().fg(Color::Cyan),
377 )));
378 }
379 lines.push(Line::from(Span::styled(
380 " ```".to_string(),
381 Style::default().fg(Color::DarkGray),
382 )));
383}
384
385fn get_current_md_content(app: &App) -> Option<String> {
386 let path = get_current_md_path(app)?;
387 std::fs::read_to_string(&path).ok()
388}
389
390fn get_current_md_path(app: &App) -> Option<String> {
391 let items = app.visible_items();
392 let selected = items.get(app.ui_state.selected_index)?;
393 let file_idx = match selected {
394 crate::app::VisibleItem::FileHeader { file_idx } => *file_idx,
395 crate::app::VisibleItem::HunkHeader { file_idx, .. } => *file_idx,
396 crate::app::VisibleItem::DiffLine { file_idx, .. } => *file_idx,
397 };
398 let file = app.diff_data.files.get(file_idx)?;
399 let path = file.target_file.trim_start_matches("b/");
400 if path.ends_with(".md") || path.ends_with(".markdown") || path.ends_with(".mdown") {
401 Some(path.to_string())
402 } else {
403 None
404 }
405}
406
407pub fn is_current_file_markdown(app: &App) -> bool {
408 get_current_md_path(app).is_some()
409}