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    if word.kind() == WordType::Code {
327        return style_word_content(word, word.content());
328    }
329    match indent {
330        1 => Span::styled(
331            word.content(),
332            Style::default().fg(color_config().heading_fg_color),
333        ),
334        2 => Span::styled(
335            word.content(),
336            Style::default().fg(heading_colors().level_2),
337        ),
338        3 => Span::styled(
339            word.content(),
340            Style::default().fg(heading_colors().level_3),
341        ),
342        4 => Span::styled(
343            word.content(),
344            Style::default().fg(heading_colors().level_4),
345        ),
346        5 => Span::styled(
347            word.content(),
348            Style::default().fg(heading_colors().level_5),
349        ),
350        6 => Span::styled(
351            word.content(),
352            Style::default().fg(heading_colors().level_6),
353        ),
354        _ => Span::styled(
355            word.content(),
356            Style::default().fg(color_config().heading_fg_color),
357        ),
358    }
359}
360
361fn render_heading(area: Rect, buf: &mut Buffer, component: TextComponent) {
362    let indent = if let Some(meta) = component.meta_info().first() {
363        match meta.kind() {
364            WordType::MetaInfo(MetaData::HeadingLevel(e)) => e,
365            _ => 1,
366        }
367    } else {
368        1
369    };
370
371    let content: Vec<Span<'_>> = component
372        .content()
373        .iter()
374        .flatten()
375        .map(|c| style_heading(c, indent))
376        .collect();
377
378    let paragraph = match indent {
379        1 => Paragraph::new(Line::from(content))
380            .block(Block::default().style(Style::default().bg(color_config().heading_bg_color)))
381            .alignment(Alignment::Center),
382        _ => Paragraph::new(Line::from(content)),
383    };
384
385    paragraph.render(area, buf);
386}
387
388fn render_paragraph(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
389    let top = component
390        .scroll_offset()
391        .saturating_sub(component.y_offset());
392    let mut content = component.content_owned();
393    let content = match clip {
394        Clipping::Both => {
395            content.drain(0..top as usize);
396            content.drain(area.height as usize..);
397            content
398        }
399        Clipping::Upper => {
400            let len = content.len();
401            let height = area.height;
402            let offset = len - height as usize;
403            let mut content = content;
404            content.drain(0..offset);
405            content
406        }
407        Clipping::Lower => {
408            let mut content = content;
409            content.drain(area.height as usize..);
410            content
411        }
412        Clipping::None => content,
413    };
414
415    let lines = content
416        .iter()
417        .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
418        .collect::<Vec<_>>();
419
420    let paragraph = Paragraph::new(lines);
421
422    paragraph.render(area, buf);
423}
424
425fn render_details_summary(area: Rect, buf: &mut Buffer, component: TextComponent, folded: bool) {
426    let focused = component.is_focused();
427    let mut style = Style::default().add_modifier(Modifier::BOLD);
428    if focused {
429        style = style
430            .fg(color_config().link_selected_fg_color)
431            .bg(color_config().link_selected_bg_color);
432    }
433    let marker = if folded { "▶ " } else { "▼ " };
434    let mut spans: Vec<Span> = vec![Span::styled(marker, style)];
435    for word in component.content_owned().into_iter().flatten() {
436        spans.push(Span::styled(word.content().to_string(), style));
437    }
438    Paragraph::new(Line::from(spans)).render(area, buf);
439}
440
441fn render_list(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
442    let top = component
443        .scroll_offset()
444        .saturating_sub(component.y_offset());
445    let mut content = component.content_owned();
446    let content = match clip {
447        Clipping::Both => {
448            content.drain(0..top as usize);
449            content.drain(area.height as usize..);
450            content
451        }
452        Clipping::Upper => {
453            let len = content.len();
454            let height = area.height;
455            let offset = len - height as usize;
456            let mut content = content;
457            content.drain(0..offset);
458            content
459        }
460        Clipping::Lower => {
461            let mut content = content;
462            content.drain(area.height as usize..);
463            content
464        }
465        Clipping::None => content,
466    };
467    let content: Vec<ListItem<'_>> = content
468        .iter()
469        .map(|c| -> ListItem<'_> {
470            ListItem::new(Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
471        })
472        .collect();
473
474    let list = List::new(content);
475    list.render(area, buf);
476}
477
478fn render_code_block(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
479    let mut content = component
480        .content()
481        .iter()
482        .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
483        .collect::<Vec<_>>();
484
485    let max_width = cmp::max(
486        component
487            .meta_info()
488            .iter()
489            .find_map(|f| match f.kind() {
490                WordType::MetaInfo(MetaData::LineLength(len)) => Some(len),
491                _ => None,
492            })
493            .unwrap_or(area.width)
494            + 2,
495        area.width,
496    );
497
498    match clip {
499        Clipping::Both => {
500            let top = component.scroll_offset() - component.y_offset();
501            content.drain(0..top as usize);
502            content.drain(area.height as usize..);
503        }
504        Clipping::Upper => {
505            let len = content.len();
506            let height = area.height;
507            let offset = len - height as usize;
508            // panic!("offset: {}, height: {}, len: {}", offset, height, len);
509            content.drain(0..offset);
510        }
511        Clipping::Lower => {
512            content.drain(area.height as usize..);
513        }
514        Clipping::None => (),
515    }
516
517    let block = Block::default().style(Style::default().bg(color_config().code_block_bg_color));
518
519    let area = Rect {
520        width: max_width,
521        ..area
522    };
523
524    block.render(area, buf);
525
526    let area = if let Some(word) = component.meta_info().first()
527        && matches!(word.content(), "mermaid")
528    {
529        Rect {
530            x: area.x + 1,
531            width: buf.area().width,
532            ..area
533        }
534    } else {
535        Rect {
536            x: area.x + 1,
537            width: area.width - 1,
538            ..area
539        }
540    };
541
542    let paragraph = Paragraph::new(content);
543
544    paragraph.render(area, buf);
545}
546
547fn render_table(
548    area: Rect,
549    buf: &mut Buffer,
550    component: TextComponent,
551    clip: Clipping,
552    widths: Vec<u16>,
553    heights: Vec<u16>,
554) {
555    let column_count = widths.len();
556
557    if column_count == 0 {
558        Paragraph::new(Line::from("Malformed table").fg(Color::Red)).render(area, buf);
559        return;
560    }
561
562    let top = component
563        .scroll_offset()
564        .saturating_sub(component.y_offset());
565
566    let mut lines = build_table_lines(component.content(), &widths, &heights);
567
568    let lines = match clip {
569        Clipping::Both => {
570            lines.drain(0..top as usize);
571            lines.drain(area.height as usize..);
572            lines
573        }
574        Clipping::Upper => {
575            let offset = lines.len().saturating_sub(area.height as usize);
576            lines.drain(0..offset);
577            lines
578        }
579        Clipping::Lower => {
580            lines.drain(area.height as usize..);
581            lines
582        }
583        Clipping::None => lines,
584    };
585
586    Paragraph::new(lines).render(area, buf);
587}
588
589fn render_task(
590    area: Rect,
591    buf: &mut Buffer,
592    component: TextComponent,
593    clip: Clipping,
594    meta_info: &Word,
595) {
596    const CHECKBOX: &str = "✅ ";
597    const UNCHECKED: &str = "❌ ";
598
599    let checkbox = if meta_info.content() == "- [ ] " {
600        UNCHECKED
601    } else {
602        CHECKBOX
603    };
604
605    let paragraph = Paragraph::new(checkbox);
606
607    paragraph.render(area, buf);
608
609    let area = Rect {
610        x: area.x + 4,
611        width: area.width - 4,
612        ..area
613    };
614
615    let top = component
616        .scroll_offset()
617        .saturating_sub(component.y_offset());
618
619    let mut content = component.content_owned();
620
621    let content = match clip {
622        Clipping::Both => {
623            content.drain(0..top as usize);
624            content.drain(area.height as usize..);
625            content
626        }
627        Clipping::Upper => {
628            let len = content.len();
629            let height = area.height;
630            let offset = len - height as usize;
631            let mut content = content;
632            content.drain(0..offset);
633            content
634        }
635        Clipping::Lower => {
636            let mut content = content;
637            content.drain(area.height as usize..);
638            content
639        }
640        Clipping::None => content,
641    };
642
643    let lines = content
644        .iter()
645        .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
646        .collect::<Vec<_>>();
647
648    let paragraph = Paragraph::new(lines);
649
650    paragraph.render(area, buf);
651}
652
653fn render_horizontal_separator(area: Rect, buf: &mut Buffer) {
654    let paragraph = Paragraph::new(Line::from(vec![Span::raw(
655        "\u{2014}".repeat(GENERAL_CONFIG.width.into()),
656    )]));
657
658    paragraph.render(area, buf);
659}