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, area.width, &app.theme);
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 {
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
152pub fn flush_images(
156 images: &[PendingImage],
157 protocol: ImageProtocol,
158) {
159 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
184pub 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 if protocol == ImageProtocol::Kitty {
199 let _ = write!(stdout, "\x1b_Ga=d,d=a;\x1b\\");
200 }
201
202 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 let _ = terminal.clear();
215}
216
217fn 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 let _ = write!(stdout, "\x1b[{};{}H", img.y + 1, img.x + 1);
225 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
234fn 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 let _ = write!(stdout, "\x1b[{};{}H", img.y + 1, img.x + 1);
242
243 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
348fn 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 let natural_cols = (img_w / 8.0).ceil() as u16;
360 let display_cols = natural_cols.min(pane_width);
362 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
375fn 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}