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);
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 =
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
346fn 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 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}