Skip to main content

md_tui/pages/
markdown_renderer.rs

1use std::borrow::Cow;
2use std::cmp;
3
4use ratatui::{
5    buffer::Buffer,
6    layout::{Alignment, Rect},
7    style::{Color, Modifier, Style, Stylize},
8    text::{Line, Span},
9    widgets::{Block, List, ListItem, Paragraph, Widget},
10};
11
12use crate::{
13    nodes::{
14        textcomponent::{
15            TABLE_CELL_PADDING, TextComponent, TextNode, content_entry_len, word_wrapping,
16        },
17        word::{MetaData, Word, WordType},
18    },
19    util::{
20        colors::{color_config, heading_colors},
21        general::GENERAL_CONFIG,
22    },
23};
24
25fn clips_upper_bound(_area: Rect, component: &TextComponent) -> bool {
26    component.scroll_offset() > component.y_offset()
27}
28
29fn clips_lower_bound(area: Rect, component: &TextComponent) -> bool {
30    (component.y_offset() + component.height()).saturating_sub(component.scroll_offset())
31        > area.height
32}
33
34enum Clipping {
35    Both,
36    Upper,
37    Lower,
38    None,
39}
40
41impl Widget for TextComponent {
42    fn render(self, area: Rect, buf: &mut Buffer) {
43        let kind = self.kind();
44
45        let y = self.y_offset().saturating_sub(self.scroll_offset());
46
47        let clips = if clips_upper_bound(area, &self) && clips_lower_bound(area, &self) {
48            Clipping::Both
49        } else if clips_upper_bound(area, &self) {
50            Clipping::Upper
51        } else if clips_lower_bound(area, &self) {
52            Clipping::Lower
53        } else {
54            Clipping::None
55        };
56
57        let height = match clips {
58            Clipping::Both => {
59                let new_y = self.y_offset().saturating_sub(self.scroll_offset());
60                let new_height = new_y;
61                cmp::min(self.height(), area.height.saturating_sub(new_height))
62            }
63
64            Clipping::Upper => cmp::min(
65                self.height(),
66                (self.height() + self.y_offset()).saturating_sub(self.scroll_offset()),
67            ),
68            Clipping::Lower => {
69                let new_y = self.y_offset() - self.scroll_offset();
70                let new_height = new_y;
71                cmp::min(self.height(), area.height.saturating_sub(new_height))
72            }
73            Clipping::None => self.height(),
74        };
75
76        let meta_info = self
77            .meta_info()
78            .to_owned()
79            .first()
80            .cloned()
81            .unwrap_or_else(|| Word::new(String::new(), WordType::Normal));
82
83        let area = Rect { height, y, ..area };
84
85        match kind {
86            TextNode::Paragraph => render_paragraph(area, buf, self, clips),
87            TextNode::Heading => render_heading(area, buf, self),
88            TextNode::Task => render_task(area, buf, self, clips, &meta_info),
89            TextNode::List => render_list(area, buf, self, clips),
90            TextNode::CodeBlock => render_code_block(area, buf, self, clips),
91            TextNode::Table(widths, heights) => {
92                render_table(area, buf, self, clips, widths, heights);
93            }
94            TextNode::Quote => render_quote(area, buf, self, clips),
95            TextNode::LineBreak => (),
96            TextNode::HorizontalSeparator => render_horizontal_separator(area, buf),
97            TextNode::Image => todo!(),
98            TextNode::Footnote => (),
99            TextNode::DetailsSummary { folded, .. } => {
100                render_details_summary(area, buf, self, folded);
101            }
102        }
103    }
104}
105
106fn style_word_content<'a>(word: &Word, content: impl Into<Cow<'a, str>>) -> Span<'a> {
107    match word.kind() {
108        WordType::MetaInfo(_) | WordType::LinkData | WordType::FootnoteData => unreachable!(),
109        WordType::Selected => Span::styled(
110            content,
111            Style::default()
112                .fg(color_config().link_selected_fg_color)
113                .bg(color_config().link_selected_bg_color),
114        ),
115        WordType::Normal => Span::raw(content),
116        WordType::Code => Span::styled(content, Style::default().fg(color_config().code_fg_color))
117            .bg(color_config().code_bg_color),
118        WordType::Link | WordType::FootnoteInline => {
119            Span::styled(content, Style::default().fg(color_config().link_color))
120        }
121        WordType::Italic => Span::styled(
122            content,
123            Style::default().fg(color_config().italic_color).italic(),
124        ),
125        WordType::Bold => Span::styled(
126            content,
127            Style::default().fg(color_config().bold_color).bold(),
128        ),
129        WordType::Strikethrough | WordType::Footnote => Span::styled(
130            content,
131            Style::default()
132                .fg(color_config().striketrough_color)
133                .add_modifier(Modifier::CROSSED_OUT),
134        ),
135        WordType::White => Span::styled(content, Style::default().fg(Color::White)),
136        WordType::ListMarker => Span::styled(content, Style::default().fg(Color::White)),
137        WordType::BoldItalic => Span::styled(
138            content,
139            Style::default()
140                .fg(color_config().bold_italic_color)
141                .add_modifier(Modifier::BOLD)
142                .add_modifier(Modifier::ITALIC),
143        ),
144        WordType::CodeBlock(e) => Span::styled(content, e),
145    }
146}
147
148fn style_word(word: &Word) -> Span<'_> {
149    style_word_content(word, word.content())
150}
151
152fn style_word_owned(word: &Word) -> Span<'static> {
153    style_word_content(word, word.content().to_owned())
154}
155
156fn wrap_table_cell(entry: &[Word], width: u16) -> Vec<Vec<Word>> {
157    if width == 0 {
158        return vec![Vec::new()];
159    }
160
161    word_wrapping(entry.iter(), width as usize, true)
162}
163
164fn table_border_line(
165    widths: &[u16],
166    left: &'static str,
167    middle: &'static str,
168    right: &'static str,
169) -> Line<'static> {
170    let mut spans = vec![Span::raw(left)];
171
172    for (column_i, width) in widths.iter().enumerate() {
173        spans.push(Span::raw(
174            "─".repeat((width + TABLE_CELL_PADDING * 2) as usize),
175        ));
176        spans.push(Span::raw(if column_i + 1 == widths.len() {
177            right
178        } else {
179            middle
180        }));
181    }
182
183    Line::from(spans)
184}
185
186fn build_table_row_lines(
187    row: &[Vec<Word>],
188    widths: &[u16],
189    row_height: u16,
190    row_style: Option<Style>,
191) -> Vec<Line<'static>> {
192    let wrapped_cells = row
193        .iter()
194        .zip(widths.iter())
195        .map(|(entry, width)| wrap_table_cell(entry, *width))
196        .collect::<Vec<_>>();
197
198    (0..row_height as usize)
199        .map(|line_i| {
200            let mut spans = vec![Span::raw("│")];
201
202            for (column_i, cell_lines) in wrapped_cells.iter().enumerate() {
203                spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING as usize)));
204
205                if let Some(words) = cell_lines.get(line_i) {
206                    spans.extend(words.iter().map(style_word_owned));
207
208                    let padding = widths[column_i] as usize - content_entry_len(words);
209                    if padding > 0 {
210                        spans.push(Span::raw(" ".repeat(padding)));
211                    }
212                } else {
213                    spans.push(Span::raw(" ".repeat(widths[column_i] as usize)));
214                }
215
216                spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING as usize)));
217                spans.push(Span::raw("│"));
218            }
219
220            let line = Line::from(spans);
221            if let Some(style) = row_style {
222                line.patch_style(style)
223            } else {
224                line
225            }
226        })
227        .collect()
228}
229
230fn build_table_lines(content: &[Vec<Word>], widths: &[u16], heights: &[u16]) -> Vec<Line<'static>> {
231    let column_count = widths.len();
232    let header_style = Style::default()
233        .fg(color_config().table_header_fg_color)
234        .bg(color_config().table_header_bg_color);
235
236    let mut lines = vec![table_border_line(widths, "╭", "┬", "╮")];
237
238    for (row_i, row) in content.chunks(column_count).enumerate() {
239        let row_style = (row_i == 0).then_some(header_style);
240        lines.extend(build_table_row_lines(
241            row,
242            widths,
243            heights[row_i],
244            row_style,
245        ));
246
247        if row_i == 0 {
248            lines.push(table_border_line(widths, "├", "┼", "┤"));
249        }
250    }
251
252    lines.push(table_border_line(widths, "╰", "┴", "╯"));
253    lines
254}
255
256fn render_quote(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
257    let top = component
258        .scroll_offset()
259        .saturating_sub(component.y_offset());
260
261    let meta = component.meta_info().to_owned();
262
263    let mut content = component.content_owned();
264    let content = match clip {
265        Clipping::Both => {
266            content.drain(0..top as usize);
267            content.drain(area.height as usize..);
268            content
269        }
270        Clipping::Upper => {
271            let len = content.len();
272            let height = area.height;
273            let offset = len - height as usize;
274            let mut content = content;
275            content.drain(0..offset);
276            content
277        }
278        Clipping::Lower => {
279            let mut content = content;
280            content.drain(area.height as usize..);
281            content
282        }
283        Clipping::None => content,
284    };
285
286    let lines = content
287        .iter()
288        .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
289        .collect::<Vec<_>>();
290
291    let bar_color = if let Some(meta) = meta.first() {
292        meta.content()
293            .split_whitespace()
294            .next()
295            .map(str::to_lowercase)
296            .map_or(color_config().quote_bg_color, |c| match c.as_str() {
297                "[!tip]" => color_config().quote_tip,
298                "[!warning]" => color_config().quote_warning,
299                "[!caution]" => color_config().quote_caution,
300                "[!important]" => color_config().quote_important,
301                "[!note]" => color_config().quote_note,
302                _ => color_config().quote_default,
303            })
304    } else {
305        Color::White
306    };
307    let vertical_marker = Span::styled("\u{2588}", Style::default().fg(bar_color));
308
309    let marker_paragraph = Paragraph::new(vec![Line::from(vertical_marker); content.len()])
310        .bg(color_config().quote_bg_color);
311    marker_paragraph.render(area, buf);
312
313    let paragraph = Paragraph::new(lines)
314        .block(Block::default().style(Style::default().bg(color_config().quote_bg_color)));
315
316    let area = Rect {
317        x: area.x + 1,
318        width: cmp::min(area.width, GENERAL_CONFIG.width) - 1,
319        ..area
320    };
321
322    paragraph.render(area, buf);
323}
324
325fn style_heading(word: &Word, indent: u8) -> Span<'_> {
326    match indent {
327        1 => Span::styled(
328            word.content(),
329            Style::default().fg(color_config().heading_fg_color),
330        ),
331        2 => Span::styled(
332            word.content(),
333            Style::default().fg(heading_colors().level_2),
334        ),
335        3 => Span::styled(
336            word.content(),
337            Style::default().fg(heading_colors().level_3),
338        ),
339        4 => Span::styled(
340            word.content(),
341            Style::default().fg(heading_colors().level_4),
342        ),
343        5 => Span::styled(
344            word.content(),
345            Style::default().fg(heading_colors().level_5),
346        ),
347        6 => Span::styled(
348            word.content(),
349            Style::default().fg(heading_colors().level_6),
350        ),
351        _ => Span::styled(
352            word.content(),
353            Style::default().fg(color_config().heading_fg_color),
354        ),
355    }
356}
357
358fn render_heading(area: Rect, buf: &mut Buffer, component: TextComponent) {
359    let indent = if let Some(meta) = component.meta_info().first() {
360        match meta.kind() {
361            WordType::MetaInfo(MetaData::HeadingLevel(e)) => e,
362            _ => 1,
363        }
364    } else {
365        1
366    };
367
368    let content: Vec<Span<'_>> = component
369        .content()
370        .iter()
371        .flatten()
372        .map(|c| style_heading(c, indent))
373        .collect();
374
375    let paragraph = match indent {
376        1 => Paragraph::new(Line::from(content))
377            .block(Block::default().style(Style::default().bg(color_config().heading_bg_color)))
378            .alignment(Alignment::Center),
379        _ => Paragraph::new(Line::from(content)),
380    };
381
382    paragraph.render(area, buf);
383}
384
385fn render_paragraph(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
386    let top = component
387        .scroll_offset()
388        .saturating_sub(component.y_offset());
389    let mut content = component.content_owned();
390    let content = match clip {
391        Clipping::Both => {
392            content.drain(0..top as usize);
393            content.drain(area.height as usize..);
394            content
395        }
396        Clipping::Upper => {
397            let len = content.len();
398            let height = area.height;
399            let offset = len - height as usize;
400            let mut content = content;
401            content.drain(0..offset);
402            content
403        }
404        Clipping::Lower => {
405            let mut content = content;
406            content.drain(area.height as usize..);
407            content
408        }
409        Clipping::None => content,
410    };
411
412    let lines = content
413        .iter()
414        .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
415        .collect::<Vec<_>>();
416
417    let paragraph = Paragraph::new(lines);
418
419    paragraph.render(area, buf);
420}
421
422fn render_details_summary(area: Rect, buf: &mut Buffer, component: TextComponent, folded: bool) {
423    let focused = component.is_focused();
424    let mut style = Style::default().add_modifier(Modifier::BOLD);
425    if focused {
426        style = style
427            .fg(color_config().link_selected_fg_color)
428            .bg(color_config().link_selected_bg_color);
429    }
430    let marker = if folded { "▶ " } else { "▼ " };
431    let mut spans: Vec<Span> = vec![Span::styled(marker, style)];
432    for word in component.content_owned().into_iter().flatten() {
433        spans.push(Span::styled(word.content().to_string(), style));
434    }
435    Paragraph::new(Line::from(spans)).render(area, buf);
436}
437
438fn render_list(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
439    let top = component
440        .scroll_offset()
441        .saturating_sub(component.y_offset());
442    let mut content = component.content_owned();
443    let content = match clip {
444        Clipping::Both => {
445            content.drain(0..top as usize);
446            content.drain(area.height as usize..);
447            content
448        }
449        Clipping::Upper => {
450            let len = content.len();
451            let height = area.height;
452            let offset = len - height as usize;
453            let mut content = content;
454            content.drain(0..offset);
455            content
456        }
457        Clipping::Lower => {
458            let mut content = content;
459            content.drain(area.height as usize..);
460            content
461        }
462        Clipping::None => content,
463    };
464    let content: Vec<ListItem<'_>> = content
465        .iter()
466        .map(|c| -> ListItem<'_> {
467            ListItem::new(Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
468        })
469        .collect();
470
471    let list = List::new(content);
472    list.render(area, buf);
473}
474
475fn render_code_block(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
476    let mut content = component
477        .content()
478        .iter()
479        .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
480        .collect::<Vec<_>>();
481
482    let max_width = cmp::max(
483        component
484            .meta_info()
485            .iter()
486            .find_map(|f| match f.kind() {
487                WordType::MetaInfo(MetaData::LineLength(len)) => Some(len),
488                _ => None,
489            })
490            .unwrap_or(area.width)
491            + 2,
492        area.width,
493    );
494
495    match clip {
496        Clipping::Both => {
497            let top = component.scroll_offset() - component.y_offset();
498            content.drain(0..top as usize);
499            content.drain(area.height as usize..);
500        }
501        Clipping::Upper => {
502            let len = content.len();
503            let height = area.height;
504            let offset = len - height as usize;
505            // panic!("offset: {}, height: {}, len: {}", offset, height, len);
506            content.drain(0..offset);
507        }
508        Clipping::Lower => {
509            content.drain(area.height as usize..);
510        }
511        Clipping::None => (),
512    }
513
514    let block = Block::default().style(Style::default().bg(color_config().code_block_bg_color));
515
516    let area = Rect {
517        width: max_width,
518        ..area
519    };
520
521    block.render(area, buf);
522
523    let area = if let Some(word) = component.meta_info().first()
524        && matches!(word.content(), "mermaid")
525    {
526        Rect {
527            x: area.x + 1,
528            width: buf.area().width,
529            ..area
530        }
531    } else {
532        Rect {
533            x: area.x + 1,
534            width: area.width - 1,
535            ..area
536        }
537    };
538
539    let paragraph = Paragraph::new(content);
540
541    paragraph.render(area, buf);
542}
543
544fn render_table(
545    area: Rect,
546    buf: &mut Buffer,
547    component: TextComponent,
548    clip: Clipping,
549    widths: Vec<u16>,
550    heights: Vec<u16>,
551) {
552    let column_count = widths.len();
553
554    if column_count == 0 {
555        Paragraph::new(Line::from("Malformed table").fg(Color::Red)).render(area, buf);
556        return;
557    }
558
559    let top = component
560        .scroll_offset()
561        .saturating_sub(component.y_offset());
562
563    let mut lines = build_table_lines(component.content(), &widths, &heights);
564
565    let lines = match clip {
566        Clipping::Both => {
567            lines.drain(0..top as usize);
568            lines.drain(area.height as usize..);
569            lines
570        }
571        Clipping::Upper => {
572            let offset = lines.len().saturating_sub(area.height as usize);
573            lines.drain(0..offset);
574            lines
575        }
576        Clipping::Lower => {
577            lines.drain(area.height as usize..);
578            lines
579        }
580        Clipping::None => lines,
581    };
582
583    Paragraph::new(lines).render(area, buf);
584}
585
586fn render_task(
587    area: Rect,
588    buf: &mut Buffer,
589    component: TextComponent,
590    clip: Clipping,
591    meta_info: &Word,
592) {
593    const CHECKBOX: &str = "✅ ";
594    const UNCHECKED: &str = "❌ ";
595
596    let checkbox = if meta_info.content() == "- [ ] " {
597        UNCHECKED
598    } else {
599        CHECKBOX
600    };
601
602    let paragraph = Paragraph::new(checkbox);
603
604    paragraph.render(area, buf);
605
606    let area = Rect {
607        x: area.x + 4,
608        width: area.width - 4,
609        ..area
610    };
611
612    let top = component
613        .scroll_offset()
614        .saturating_sub(component.y_offset());
615
616    let mut content = component.content_owned();
617
618    let content = match clip {
619        Clipping::Both => {
620            content.drain(0..top as usize);
621            content.drain(area.height as usize..);
622            content
623        }
624        Clipping::Upper => {
625            let len = content.len();
626            let height = area.height;
627            let offset = len - height as usize;
628            let mut content = content;
629            content.drain(0..offset);
630            content
631        }
632        Clipping::Lower => {
633            let mut content = content;
634            content.drain(area.height as usize..);
635            content
636        }
637        Clipping::None => content,
638    };
639
640    let lines = content
641        .iter()
642        .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
643        .collect::<Vec<_>>();
644
645    let paragraph = Paragraph::new(lines);
646
647    paragraph.render(area, buf);
648}
649
650fn render_horizontal_separator(area: Rect, buf: &mut Buffer) {
651    let paragraph = Paragraph::new(Line::from(vec![Span::raw(
652        "\u{2014}".repeat(GENERAL_CONFIG.width.into()),
653    )]));
654
655    paragraph.render(area, buf);
656}