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;
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()).scroll((clip_top, 0));
121 frame.render_widget(para, seg_area);
122 }
123 Segment::Image { ref path } => {
124 let blank_lines: Vec<Line> = (0..visible_h).map(|_| Line::raw("")).collect();
126 frame.render_widget(Paragraph::new(blank_lines), seg_area);
127
128 if clip_top == 0 {
130 pending_images.push(PendingImage {
131 path: path.clone(),
132 x: seg_area.x,
133 y: seg_area.y,
134 width: seg_area.width,
135 height: seg_area.height,
136 });
137 }
138 }
139 }
140
141 y_screen += visible_h;
142 y_content = seg_end;
143 }
144
145 pending_images
146}
147
148pub fn flush_images(
152 images: &[PendingImage],
153 protocol: ImageProtocol,
154) {
155 if images.is_empty() {
158 return;
159 }
160
161 let mut stdout = std::io::stdout();
162 for img in images {
163 let png_data = match std::fs::read(&img.path) {
164 Ok(data) => data,
165 Err(_) => continue,
166 };
167
168 match protocol {
169 ImageProtocol::Iterm2 => {
170 write_iterm2_image(&mut stdout, &png_data, img);
171 }
172 ImageProtocol::Kitty => {
173 write_kitty_image(&mut stdout, &png_data, img);
174 }
175 }
176 }
177 let _ = stdout.flush();
178}
179
180pub fn clear_stale_images(
183 protocol: ImageProtocol,
184 terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
185) {
186 if protocol == ImageProtocol::Kitty {
188 let mut stdout = std::io::stdout();
189 let _ = write!(stdout, "\x1b_Ga=d;\x1b\\");
190 let _ = stdout.flush();
191 }
192 let _ = terminal.clear();
195}
196
197fn write_iterm2_image(stdout: &mut impl Write, png_data: &[u8], img: &PendingImage) {
200 use base64::Engine;
201 let b64 = base64::engine::general_purpose::STANDARD.encode(png_data);
202
203 let _ = write!(stdout, "\x1b[{};{}H", img.y + 1, img.x + 1);
205 let _ = write!(
207 stdout,
208 "\x1b]1337;File=inline=1;width={w};height={h};preserveAspectRatio=1:{b64}\x07",
209 w = img.width,
210 h = img.height,
211 );
212}
213
214fn write_kitty_image(stdout: &mut impl Write, png_data: &[u8], img: &PendingImage) {
217 use base64::Engine;
218 let b64 = base64::engine::general_purpose::STANDARD.encode(png_data);
219
220 let _ = write!(stdout, "\x1b[{};{}H", img.y + 1, img.x + 1);
222
223 let chunks: Vec<&str> = b64
225 .as_bytes()
226 .chunks(4096)
227 .map(|c| std::str::from_utf8(c).unwrap_or(""))
228 .collect();
229 for (i, chunk) in chunks.iter().enumerate() {
230 let more = if i < chunks.len() - 1 { 1 } else { 0 };
231 if i == 0 {
232 let _ = write!(
233 stdout,
234 "\x1b_Ga=T,f=100,t=d,c={},r={},m={more};{chunk}\x1b\\",
235 img.width, img.height
236 );
237 } else {
238 let _ = write!(stdout, "\x1b_Gm={more};{chunk}\x1b\\");
239 }
240 }
241}
242
243enum Segment {
244 Text(Vec<Line<'static>>),
245 Image { path: std::path::PathBuf },
246}
247
248impl Segment {
249 fn height(&self, pane_width: u16) -> u16 {
250 match self {
251 Segment::Text(lines) => lines.len() as u16,
252 Segment::Image { ref path } => estimate_image_height(path, pane_width),
253 }
254 }
255}
256
257fn build_mermaid_segment(
258 app: &App,
259 block: &crate::preview::mermaid::MermaidBlock,
260 segments: &mut Vec<Segment>,
261) {
262 let state = app
263 .mermaid_cache
264 .as_ref()
265 .map(|c| c.get_state_blocking(&block.hash))
266 .unwrap_or(MermaidRenderState::Pending);
267
268 match state {
269 MermaidRenderState::Ready(path) => {
270 segments.push(Segment::Image { path });
271 segments.push(Segment::Text(vec![Line::raw("")]));
272 }
273 MermaidRenderState::Rendering => {
274 segments.push(Segment::Text(vec![
275 Line::from(Span::styled(
276 " [Rendering diagram...]",
277 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
278 )),
279 Line::raw(""),
280 ]));
281 }
282 MermaidRenderState::Pending => {
283 if let Some(ref cache) = app.mermaid_cache {
284 if let Some(ref tx) = app.event_tx {
285 cache.render_async(block.clone(), tx.clone());
286 }
287 }
288 segments.push(Segment::Text(vec![
289 Line::from(Span::styled(
290 " [Rendering diagram...]",
291 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
292 )),
293 Line::raw(""),
294 ]));
295 }
296 MermaidRenderState::Failed(err) => {
297 let mut lines = vec![
298 Line::from(Span::styled(
299 format!(" [Diagram error: {err}]"),
300 Style::default().fg(Color::Red).add_modifier(Modifier::DIM),
301 )),
302 Line::raw(""),
303 ];
304 render_mermaid_source(block, &mut lines);
305 lines.push(Line::raw(""));
306 segments.push(Segment::Text(lines));
307 }
308 }
309}
310
311fn estimate_image_height(path: &std::path::Path, pane_width: u16) -> u16 {
315 if let Ok(img) = image::open(path) {
316 let (img_w, img_h) = (img.width() as f64, img.height() as f64);
317 if img_w > 0.0 {
318 let aspect = img_h / img_w;
321 let rows = (pane_width as f64 * aspect / 2.0).ceil() as u16;
322 rows.clamp(3, 50)
323 } else {
324 10
325 }
326 } else {
327 10
328 }
329}
330
331fn render_mermaid_source(
332 block: &crate::preview::mermaid::MermaidBlock,
333 lines: &mut Vec<Line<'static>>,
334) {
335 lines.push(Line::from(Span::styled(
336 " ```mermaid".to_string(),
337 Style::default().fg(Color::DarkGray),
338 )));
339 for src_line in block.source.lines() {
340 lines.push(Line::from(Span::styled(
341 format!(" {src_line}"),
342 Style::default().fg(Color::Cyan),
343 )));
344 }
345 lines.push(Line::from(Span::styled(
346 " ```".to_string(),
347 Style::default().fg(Color::DarkGray),
348 )));
349}
350
351fn get_current_md_content(app: &App) -> Option<String> {
352 let path = get_current_md_path(app)?;
353 std::fs::read_to_string(&path).ok()
354}
355
356fn get_current_md_path(app: &App) -> Option<String> {
357 let items = app.visible_items();
358 let selected = items.get(app.ui_state.selected_index)?;
359 let file_idx = match selected {
360 crate::app::VisibleItem::FileHeader { file_idx } => *file_idx,
361 crate::app::VisibleItem::HunkHeader { file_idx, .. } => *file_idx,
362 crate::app::VisibleItem::DiffLine { file_idx, .. } => *file_idx,
363 };
364 let file = app.diff_data.files.get(file_idx)?;
365 let path = file.target_file.trim_start_matches("b/");
366 if path.ends_with(".md") || path.ends_with(".markdown") || path.ends_with(".mdown") {
367 Some(path.to_string())
368 } else {
369 None
370 }
371}
372
373pub fn is_current_file_markdown(app: &App) -> bool {
374 get_current_md_path(app).is_some()
375}