Skip to main content

mq_edit/renderer/
markdown.rs

1use ratatui::{
2    style::{Color, Modifier, Style},
3    text::Span,
4};
5
6use super::Renderer;
7use crate::document::{DocumentBuffer, LineType, TableAlignment};
8
9/// Markdown renderer for rich text display
10pub struct MarkdownRenderer {
11    /// Theme colors and styles
12    heading1_style: Style,
13    heading2_style: Style,
14    heading3_style: Style,
15    heading4_style: Style,
16    heading_other_style: Style,
17    code_block_style: Style,
18    quote_style: Style,
19    quote_border_style: Style,
20    /// Table styles
21    table_border_style: Style,
22    table_header_style: Style,
23    table_cell_style: Style,
24}
25
26impl MarkdownRenderer {
27    pub fn new() -> Self {
28        Self {
29            heading1_style: Style::default()
30                .fg(Color::Cyan)
31                .add_modifier(Modifier::BOLD),
32            heading2_style: Style::default()
33                .fg(Color::Blue)
34                .add_modifier(Modifier::BOLD),
35            heading3_style: Style::default()
36                .fg(Color::Magenta)
37                .add_modifier(Modifier::BOLD),
38            heading4_style: Style::default()
39                .fg(Color::Yellow)
40                .add_modifier(Modifier::BOLD),
41            heading_other_style: Style::default().add_modifier(Modifier::BOLD),
42            code_block_style: Style::default().fg(Color::Green),
43            quote_style: Style::default().fg(Color::Gray),
44            quote_border_style: Style::default().fg(Color::DarkGray),
45            table_border_style: Style::default().fg(Color::DarkGray),
46            table_header_style: Style::default()
47                .fg(Color::Cyan)
48                .add_modifier(Modifier::BOLD),
49            table_cell_style: Style::default(),
50        }
51    }
52
53    /// Render a line as either source (if is_current) or rich formatted (legacy method)
54    pub fn render_line_with_type(
55        &self,
56        content: &str,
57        line_type: &LineType,
58        is_current: bool,
59    ) -> Vec<Span<'_>> {
60        if is_current {
61            // Current line: show source
62            vec![Span::styled(content.to_string(), Style::default())]
63        } else {
64            // Other lines: show rich formatted based on line type
65            self.render_rich(content, line_type)
66        }
67    }
68
69    /// Render line as rich formatted text based on LineType
70    fn render_rich(&self, content: &str, line_type: &LineType) -> Vec<Span<'_>> {
71        match line_type {
72            LineType::Heading(level) => self.render_heading_line(content, *level),
73            LineType::ListItem => self.render_list_item(content, false, false),
74            LineType::OrderedListItem => self.render_list_item(content, true, false),
75            LineType::TaskListItem(checked) => self.render_list_item(content, false, *checked),
76            LineType::Blockquote => self.render_blockquote_line(content),
77            LineType::CodeFence(lang) => self.render_code_fence(content, lang.as_deref()),
78            LineType::InCode => self.render_code_content(content),
79            LineType::HorizontalRule => vec![Span::styled(
80                "─".repeat(80),
81                Style::default().fg(Color::DarkGray),
82            )],
83            LineType::Image(alt_text, path) => self.render_image(alt_text, path),
84            LineType::TableHeader(cells) => {
85                // Fallback rendering without context (column widths)
86                let widths: Vec<usize> = cells.iter().map(|c| c.chars().count()).collect();
87                let alignments = vec![TableAlignment::Left; cells.len()];
88                self.render_table_header(cells, &widths, &alignments)
89            }
90            LineType::TableSeparator(alignments) => {
91                // Fallback rendering without context
92                let widths = vec![10; alignments.len()];
93                self.render_table_separator(&widths, alignments)
94            }
95            LineType::TableRow(cells) => {
96                // Fallback rendering without context
97                let widths: Vec<usize> = cells.iter().map(|c| c.chars().count()).collect();
98                let alignments = vec![TableAlignment::Left; cells.len()];
99                self.render_table_row(cells, &widths, &alignments)
100            }
101            LineType::FrontMatterDelimiter => self.render_front_matter_delimiter(),
102            LineType::FrontMatterContent => self.render_front_matter_content(content),
103            LineType::Text => self.render_text_line(content),
104        }
105    }
106
107    /// Render heading line
108    fn render_heading_line(&self, content: &str, level: usize) -> Vec<Span<'_>> {
109        self.render_heading_line_with_width(content, level, None)
110    }
111
112    /// Render heading line with optional terminal width for full-width background
113    pub fn render_heading_line_with_width(
114        &self,
115        content: &str,
116        level: usize,
117        terminal_width: Option<usize>,
118    ) -> Vec<Span<'_>> {
119        let style = match level {
120            1 => self.heading1_style,
121            2 => self.heading2_style,
122            3 => self.heading3_style,
123            4 => self.heading4_style,
124            _ => self.heading_other_style,
125        };
126
127        // Extract text after heading markers
128        let text = content.trim_start_matches('#').trim();
129
130        // Add visual prefix for headings - make level more obvious
131        let prefix = match level {
132            1 => "# ",      // H1
133            2 => "## ",     // H2
134            3 => "### ",    // H3
135            4 => "#### ",   // H4
136            5 => "##### ",  // H5
137            _ => "###### ", // H6+
138        };
139
140        // Add background color for headers - matching the text color tone
141        let bg_color = match level {
142            1 => Color::Rgb(0, 60, 80),  // Dark cyan matching Cyan text
143            2 => Color::Rgb(0, 40, 100), // Dark blue matching Blue text
144            3 => Color::Rgb(60, 0, 80),  // Dark magenta matching Magenta text
145            4 => Color::Rgb(80, 60, 0),  // Dark yellow matching Yellow text
146            _ => Color::Rgb(40, 40, 40), // Dark gray for others
147        };
148        let style_with_bg = style.bg(bg_color);
149
150        let heading_text = format!("{}{}", prefix, text);
151
152        // If terminal width is provided, pad to fill the line
153        if let Some(width) = terminal_width {
154            let text_len = heading_text.chars().count();
155            if text_len < width {
156                let padding = " ".repeat(width - text_len);
157                vec![
158                    Span::styled(heading_text, style_with_bg),
159                    Span::styled(padding, Style::default().bg(bg_color)),
160                ]
161            } else {
162                vec![Span::styled(heading_text, style_with_bg)]
163            }
164        } else {
165            vec![Span::styled(heading_text, style_with_bg)]
166        }
167    }
168
169    /// Render list item
170    fn render_list_item(&self, content: &str, ordered: bool, task_checked: bool) -> Vec<Span<'_>> {
171        let trimmed = content.trim_start();
172
173        // Determine indentation
174        let indent_count = content.len() - trimmed.len();
175        let indent = " ".repeat(indent_count);
176
177        // Determine bullet and extract text
178        let (bullet, text) = if ordered {
179            // Find the number and ". " or ") "
180            if let Some(dot_pos) = trimmed.find(". ") {
181                let num = &trimmed[..dot_pos + 1];
182                let text = &trimmed[dot_pos + 2..];
183                (num.to_string(), text)
184            } else if let Some(paren_pos) = trimmed.find(") ") {
185                let num = &trimmed[..paren_pos + 1];
186                let text = &trimmed[paren_pos + 2..];
187                (num.to_string(), text)
188            } else {
189                ("1. ".to_string(), trimmed)
190            }
191        } else if trimmed.starts_with("- [") {
192            let bullet = if task_checked { "[✓] " } else { "[ ] " };
193            let text = trimmed
194                .strip_prefix("- [x] ")
195                .or_else(|| trimmed.strip_prefix("- [X] "))
196                .or_else(|| trimmed.strip_prefix("- [ ] "))
197                .unwrap_or(trimmed);
198            (bullet.to_string(), text)
199        } else {
200            let bullet = "• ";
201            let text = trimmed
202                .strip_prefix("- ")
203                .or_else(|| trimmed.strip_prefix("* "))
204                .or_else(|| trimmed.strip_prefix("+ "))
205                .unwrap_or(trimmed);
206            (bullet.to_string(), text)
207        };
208
209        vec![
210            Span::raw(indent),
211            Span::styled(bullet, Style::default().fg(Color::Yellow)),
212            Span::raw(text.to_string()),
213        ]
214    }
215
216    /// Render blockquote line
217    fn render_blockquote_line(&self, content: &str) -> Vec<Span<'_>> {
218        let text = content.trim_start().strip_prefix("> ").unwrap_or(content);
219        vec![
220            Span::styled("▎ ", self.quote_border_style),
221            Span::styled(text.to_string(), self.quote_style),
222        ]
223    }
224
225    /// Render code fence line (start or end of code block)
226    fn render_code_fence(&self, _content: &str, lang: Option<&str>) -> Vec<Span<'_>> {
227        if let Some(lang) = lang {
228            vec![Span::styled(
229                format!("╭─ {} ─╮", lang),
230                Style::default().fg(Color::DarkGray),
231            )]
232        } else {
233            vec![Span::styled(
234                "╭─ code ─╮",
235                Style::default().fg(Color::DarkGray),
236            )]
237        }
238    }
239
240    /// Render code fence start (opening ```lang)
241    pub fn render_code_fence_start(&self, lang: Option<&str>) -> Vec<Span<'_>> {
242        if let Some(lang) = lang {
243            vec![Span::styled(
244                format!(
245                    "╭─ {} ─────────────────────────────────────────────────────",
246                    lang
247                ),
248                Style::default().fg(Color::DarkGray),
249            )]
250        } else {
251            vec![Span::styled(
252                "╭─ code ────────────────────────────────────────────────────",
253                Style::default().fg(Color::DarkGray),
254            )]
255        }
256    }
257
258    /// Render code fence end (closing ```)
259    pub fn render_code_fence_end(&self) -> Vec<Span<'_>> {
260        vec![Span::styled(
261            "╰────────────────────────────────────────────────────────────",
262            Style::default().fg(Color::DarkGray),
263        )]
264    }
265
266    /// Render code content line (inside code block)
267    pub fn render_code_content(&self, content: &str) -> Vec<Span<'_>> {
268        vec![Span::styled(content.to_string(), self.code_block_style)]
269    }
270
271    /// Render source code as-is
272    pub fn render_source(&self, content: &str) -> Vec<Span<'_>> {
273        vec![Span::styled(content.to_string(), Style::default())]
274    }
275
276    /// Render text line with inline formatting
277    fn render_text_line(&self, content: &str) -> Vec<Span<'_>> {
278        // For now, simple rendering
279        // TODO: Parse inline formatting (bold, italic, code, links)
280        vec![Span::raw(content.to_string())]
281    }
282
283    /// Render image placeholder with detailed information
284    pub fn render_image_with_info(
285        &self,
286        alt_text: &str,
287        path: &str,
288        dimensions: Option<(u32, u32)>,
289    ) -> Vec<Span<'_>> {
290        let mut spans = vec![
291            Span::styled("🖼️  ", Style::default().fg(Color::Cyan)),
292            Span::styled(
293                format!("[{}]", alt_text),
294                Style::default()
295                    .fg(Color::Blue)
296                    .add_modifier(Modifier::ITALIC),
297            ),
298            Span::styled(" ", Style::default()),
299        ];
300
301        // Add dimensions if available
302        if let Some((width, height)) = dimensions {
303            spans.push(Span::styled(
304                format!("{}x{} ", width, height),
305                Style::default().fg(Color::Yellow),
306            ));
307        }
308
309        spans.push(Span::styled(
310            path.to_string(),
311            Style::default()
312                .fg(Color::DarkGray)
313                .add_modifier(Modifier::UNDERLINED),
314        ));
315
316        spans
317    }
318
319    /// Render image placeholder (basic version for backwards compatibility)
320    fn render_image(&self, alt_text: &str, path: &str) -> Vec<Span<'_>> {
321        vec![
322            Span::styled("🖼️  ", Style::default().fg(Color::Cyan)),
323            Span::styled(
324                format!("[{}]", alt_text),
325                Style::default()
326                    .fg(Color::Blue)
327                    .add_modifier(Modifier::ITALIC),
328            ),
329            Span::styled(" ", Style::default()),
330            Span::styled(
331                path.to_string(),
332                Style::default()
333                    .fg(Color::DarkGray)
334                    .add_modifier(Modifier::UNDERLINED),
335            ),
336        ]
337    }
338
339    /// Render table header row
340    pub fn render_table_header(
341        &self,
342        cells: &[String],
343        column_widths: &[usize],
344        alignments: &[TableAlignment],
345    ) -> Vec<Span<'_>> {
346        self.render_table_row_internal(cells, column_widths, alignments, true)
347    }
348
349    /// Render table separator row with box drawing characters
350    pub fn render_table_separator(
351        &self,
352        column_widths: &[usize],
353        alignments: &[TableAlignment],
354    ) -> Vec<Span<'_>> {
355        let mut result = String::new();
356        result.push('├');
357
358        for (i, &width) in column_widths.iter().enumerate() {
359            let left_colon = matches!(
360                alignments.get(i),
361                Some(TableAlignment::Left) | Some(TableAlignment::Center)
362            );
363            let right_colon = matches!(
364                alignments.get(i),
365                Some(TableAlignment::Right) | Some(TableAlignment::Center)
366            );
367
368            if left_colon {
369                result.push(':');
370                result.push_str(&"─".repeat(width.saturating_sub(1) + 1));
371            } else {
372                result.push_str(&"─".repeat(width + 2));
373            }
374
375            if right_colon && !left_colon {
376                // Replace last char with ':'
377                result.pop();
378                result.push(':');
379            } else if right_colon && left_colon {
380                result.pop();
381                result.push(':');
382            }
383
384            if i < column_widths.len() - 1 {
385                result.push('┼');
386            }
387        }
388        result.push('┤');
389
390        vec![Span::styled(result, self.table_border_style)]
391    }
392
393    /// Render table data row
394    pub fn render_table_row(
395        &self,
396        cells: &[String],
397        column_widths: &[usize],
398        alignments: &[TableAlignment],
399    ) -> Vec<Span<'_>> {
400        self.render_table_row_internal(cells, column_widths, alignments, false)
401    }
402
403    /// Internal helper for rendering table rows
404    fn render_table_row_internal(
405        &self,
406        cells: &[String],
407        column_widths: &[usize],
408        alignments: &[TableAlignment],
409        is_header: bool,
410    ) -> Vec<Span<'_>> {
411        let mut spans = Vec::new();
412
413        // Left border
414        spans.push(Span::styled("│", self.table_border_style));
415
416        for (i, cell) in cells.iter().enumerate() {
417            let width = column_widths
418                .get(i)
419                .copied()
420                .unwrap_or(cell.chars().count());
421            let alignment = alignments.get(i).copied().unwrap_or(TableAlignment::Left);
422
423            // Pad cell content based on alignment
424            let padded = Self::pad_cell(cell, width, alignment);
425
426            let style = if is_header {
427                self.table_header_style
428            } else {
429                self.table_cell_style
430            };
431
432            spans.push(Span::styled(format!(" {} ", padded), style));
433            spans.push(Span::styled("│", self.table_border_style));
434        }
435
436        spans
437    }
438
439    /// Pad cell content according to alignment
440    fn pad_cell(content: &str, width: usize, alignment: TableAlignment) -> String {
441        let content_width = content.chars().count();
442        if content_width >= width {
443            return content.to_string();
444        }
445
446        let padding = width - content_width;
447        match alignment {
448            TableAlignment::Left | TableAlignment::None => {
449                format!("{}{}", content, " ".repeat(padding))
450            }
451            TableAlignment::Right => {
452                format!("{}{}", " ".repeat(padding), content)
453            }
454            TableAlignment::Center => {
455                let left_pad = padding / 2;
456                let right_pad = padding - left_pad;
457                format!(
458                    "{}{}{}",
459                    " ".repeat(left_pad),
460                    content,
461                    " ".repeat(right_pad)
462                )
463            }
464        }
465    }
466
467    /// Render front matter delimiter (--- or +++)
468    fn render_front_matter_delimiter(&self) -> Vec<Span<'_>> {
469        vec![Span::styled(
470            "─".repeat(60),
471            Style::default().fg(Color::Rgb(100, 100, 150)),
472        )]
473    }
474
475    /// Render front matter content (YAML/TOML)
476    fn render_front_matter_content(&self, content: &str) -> Vec<Span<'_>> {
477        // Simple YAML syntax highlighting
478        let trimmed = content.trim_start();
479
480        // Check if it's a YAML key-value pair
481        if let Some(colon_pos) = trimmed.find(':') {
482            let key = &trimmed[..colon_pos];
483            let value = &trimmed[colon_pos..];
484
485            vec![
486                Span::styled(
487                    key.to_string(),
488                    Style::default()
489                        .fg(Color::Rgb(150, 180, 200))
490                        .add_modifier(Modifier::BOLD),
491                ),
492                Span::styled(
493                    value.to_string(),
494                    Style::default().fg(Color::Rgb(200, 200, 220)),
495                ),
496            ]
497        } else if trimmed.starts_with('-') {
498            // YAML list item
499            vec![Span::styled(
500                content.to_string(),
501                Style::default().fg(Color::Rgb(180, 180, 200)),
502            )]
503        } else if trimmed.starts_with('#') {
504            // YAML comment
505            vec![Span::styled(
506                content.to_string(),
507                Style::default()
508                    .fg(Color::DarkGray)
509                    .add_modifier(Modifier::ITALIC),
510            )]
511        } else {
512            // Default front matter style
513            vec![Span::styled(
514                content.to_string(),
515                Style::default().fg(Color::Rgb(180, 180, 200)),
516            )]
517        }
518    }
519
520    /// Determine line type with context awareness for front matter
521    fn determine_line_type(
522        &self,
523        buffer: &DocumentBuffer,
524        line_idx: usize,
525        content: &str,
526    ) -> crate::document::LineType {
527        use crate::document::LineAnalyzer;
528
529        let line_type = LineAnalyzer::analyze_line(content);
530
531        // Check if we're inside a front matter block
532        if let crate::document::LineType::FrontMatterDelimiter = line_type {
533            return line_type;
534        }
535
536        // Check if this line is inside a front matter block
537        if self.is_inside_front_matter(buffer, line_idx) {
538            return crate::document::LineType::FrontMatterContent;
539        }
540
541        line_type
542    }
543
544    /// Check if a line is inside a front matter block
545    fn is_inside_front_matter(&self, buffer: &DocumentBuffer, line_idx: usize) -> bool {
546        // Front matter must start at line 0
547        if line_idx == 0 {
548            return false;
549        }
550
551        // Check if line 0 is a front matter delimiter
552        if let Some(first_line) = buffer.line(0) {
553            let first_trimmed = first_line.trim();
554            if first_trimmed != "---" && first_trimmed != "+++" {
555                return false;
556            }
557        } else {
558            return false;
559        }
560
561        // Look backwards from current line to find if we're between delimiters
562        let mut delimiter_count = 0;
563        for i in 0..=line_idx {
564            if let Some(line) = buffer.line(i) {
565                let trimmed = line.trim();
566                if trimmed == "---" || trimmed == "+++" {
567                    delimiter_count += 1;
568                }
569            }
570        }
571
572        // If we've seen exactly one delimiter, we're inside the front matter
573        delimiter_count == 1
574    }
575}
576
577impl Default for MarkdownRenderer {
578    fn default() -> Self {
579        Self::new()
580    }
581}
582
583impl Renderer for MarkdownRenderer {
584    fn render_line(
585        &self,
586        buffer: &DocumentBuffer,
587        line_idx: usize,
588        is_current_line: bool,
589    ) -> Vec<Span<'_>> {
590        // Get line content
591        let content = buffer.line(line_idx).unwrap_or("");
592
593        if is_current_line {
594            // Current line: show source for editing
595            vec![Span::styled(content.to_string(), Style::default())]
596        } else {
597            // Other lines: show rich formatted based on line type
598            let line_type = self.determine_line_type(buffer, line_idx, content);
599            self.render_rich(content, &line_type)
600        }
601    }
602
603    fn supports_wysiwyg(&self) -> bool {
604        true // Markdown renderer supports WYSIWYG mode
605    }
606}