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        }
100    }
101}
102
103fn style_word_content<'a>(word: &Word, content: impl Into<Cow<'a, str>>) -> Span<'a> {
104    match word.kind() {
105        WordType::MetaInfo(_) | WordType::LinkData | WordType::FootnoteData => unreachable!(),
106        WordType::Selected => Span::styled(
107            content,
108            Style::default()
109                .fg(color_config().link_selected_fg_color)
110                .bg(color_config().link_selected_bg_color),
111        ),
112        WordType::Normal => Span::raw(content),
113        WordType::Code => Span::styled(content, Style::default().fg(color_config().code_fg_color))
114            .bg(color_config().code_bg_color),
115        WordType::Link | WordType::FootnoteInline => {
116            Span::styled(content, Style::default().fg(color_config().link_color))
117        }
118        WordType::Italic => Span::styled(
119            content,
120            Style::default().fg(color_config().italic_color).italic(),
121        ),
122        WordType::Bold => Span::styled(
123            content,
124            Style::default().fg(color_config().bold_color).bold(),
125        ),
126        WordType::Strikethrough | WordType::Footnote => Span::styled(
127            content,
128            Style::default()
129                .fg(color_config().striketrough_color)
130                .add_modifier(Modifier::CROSSED_OUT),
131        ),
132        WordType::White => Span::styled(content, Style::default().fg(Color::White)),
133        WordType::ListMarker => Span::styled(content, Style::default().fg(Color::White)),
134        WordType::BoldItalic => Span::styled(
135            content,
136            Style::default()
137                .fg(color_config().bold_italic_color)
138                .add_modifier(Modifier::BOLD)
139                .add_modifier(Modifier::ITALIC),
140        ),
141        WordType::CodeBlock(e) => Span::styled(content, e),
142    }
143}
144
145fn style_word(word: &Word) -> Span<'_> {
146    style_word_content(word, word.content())
147}
148
149fn style_word_owned(word: &Word) -> Span<'static> {
150    style_word_content(word, word.content().to_owned())
151}
152
153fn wrap_table_cell(entry: &[Word], width: u16) -> Vec<Vec<Word>> {
154    if width == 0 {
155        return vec![Vec::new()];
156    }
157
158    word_wrapping(entry.iter(), width as usize, true)
159}
160
161fn table_border_line(
162    widths: &[u16],
163    left: &'static str,
164    middle: &'static str,
165    right: &'static str,
166) -> Line<'static> {
167    let mut spans = vec![Span::raw(left)];
168
169    for (column_i, width) in widths.iter().enumerate() {
170        spans.push(Span::raw(
171            "─".repeat((width + TABLE_CELL_PADDING * 2) as usize),
172        ));
173        spans.push(Span::raw(if column_i + 1 == widths.len() {
174            right
175        } else {
176            middle
177        }));
178    }
179
180    Line::from(spans)
181}
182
183fn build_table_row_lines(
184    row: &[Vec<Word>],
185    widths: &[u16],
186    row_height: u16,
187    row_style: Option<Style>,
188) -> Vec<Line<'static>> {
189    let wrapped_cells = row
190        .iter()
191        .zip(widths.iter())
192        .map(|(entry, width)| wrap_table_cell(entry, *width))
193        .collect::<Vec<_>>();
194
195    (0..row_height as usize)
196        .map(|line_i| {
197            let mut spans = vec![Span::raw("│")];
198
199            for (column_i, cell_lines) in wrapped_cells.iter().enumerate() {
200                spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING as usize)));
201
202                if let Some(words) = cell_lines.get(line_i) {
203                    spans.extend(words.iter().map(style_word_owned));
204
205                    let padding = widths[column_i] as usize - content_entry_len(words);
206                    if padding > 0 {
207                        spans.push(Span::raw(" ".repeat(padding)));
208                    }
209                } else {
210                    spans.push(Span::raw(" ".repeat(widths[column_i] as usize)));
211                }
212
213                spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING as usize)));
214                spans.push(Span::raw("│"));
215            }
216
217            let line = Line::from(spans);
218            if let Some(style) = row_style {
219                line.patch_style(style)
220            } else {
221                line
222            }
223        })
224        .collect()
225}
226
227fn build_table_lines(content: &[Vec<Word>], widths: &[u16], heights: &[u16]) -> Vec<Line<'static>> {
228    let column_count = widths.len();
229    let header_style = Style::default()
230        .fg(color_config().table_header_fg_color)
231        .bg(color_config().table_header_bg_color);
232
233    let mut lines = vec![table_border_line(widths, "╭", "┬", "╮")];
234
235    for (row_i, row) in content.chunks(column_count).enumerate() {
236        let row_style = (row_i == 0).then_some(header_style);
237        lines.extend(build_table_row_lines(
238            row,
239            widths,
240            heights[row_i],
241            row_style,
242        ));
243
244        if row_i == 0 {
245            lines.push(table_border_line(widths, "├", "┼", "┤"));
246        }
247    }
248
249    lines.push(table_border_line(widths, "╰", "┴", "╯"));
250    lines
251}
252
253fn render_quote(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
254    let top = component
255        .scroll_offset()
256        .saturating_sub(component.y_offset());
257
258    let meta = component.meta_info().to_owned();
259
260    let mut content = component.content_owned();
261    let content = match clip {
262        Clipping::Both => {
263            content.drain(0..top as usize);
264            content.drain(area.height as usize..);
265            content
266        }
267        Clipping::Upper => {
268            let len = content.len();
269            let height = area.height;
270            let offset = len - height as usize;
271            let mut content = content;
272            content.drain(0..offset);
273            content
274        }
275        Clipping::Lower => {
276            let mut content = content;
277            content.drain(area.height as usize..);
278            content
279        }
280        Clipping::None => content,
281    };
282
283    let lines = content
284        .iter()
285        .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
286        .collect::<Vec<_>>();
287
288    let bar_color = if let Some(meta) = meta.first() {
289        meta.content()
290            .split_whitespace()
291            .next()
292            .map(str::to_lowercase)
293            .map_or(color_config().quote_bg_color, |c| match c.as_str() {
294                "[!tip]" => color_config().quote_tip,
295                "[!warning]" => color_config().quote_warning,
296                "[!caution]" => color_config().quote_caution,
297                "[!important]" => color_config().quote_important,
298                "[!note]" => color_config().quote_note,
299                _ => color_config().quote_default,
300            })
301    } else {
302        Color::White
303    };
304    let vertical_marker = Span::styled("\u{2588}", Style::default().fg(bar_color));
305
306    let marker_paragraph = Paragraph::new(vec![Line::from(vertical_marker); content.len()])
307        .bg(color_config().quote_bg_color);
308    marker_paragraph.render(area, buf);
309
310    let paragraph = Paragraph::new(lines)
311        .block(Block::default().style(Style::default().bg(color_config().quote_bg_color)));
312
313    let area = Rect {
314        x: area.x + 1,
315        width: cmp::min(area.width, GENERAL_CONFIG.width) - 1,
316        ..area
317    };
318
319    paragraph.render(area, buf);
320}
321
322fn style_heading(word: &Word, indent: u8) -> Span<'_> {
323    match indent {
324        1 => Span::styled(
325            word.content(),
326            Style::default().fg(color_config().heading_fg_color),
327        ),
328        2 => Span::styled(
329            word.content(),
330            Style::default().fg(heading_colors().level_2),
331        ),
332        3 => Span::styled(
333            word.content(),
334            Style::default().fg(heading_colors().level_3),
335        ),
336        4 => Span::styled(
337            word.content(),
338            Style::default().fg(heading_colors().level_4),
339        ),
340        5 => Span::styled(
341            word.content(),
342            Style::default().fg(heading_colors().level_5),
343        ),
344        6 => Span::styled(
345            word.content(),
346            Style::default().fg(heading_colors().level_6),
347        ),
348        _ => Span::styled(
349            word.content(),
350            Style::default().fg(color_config().heading_fg_color),
351        ),
352    }
353}
354
355fn render_heading(area: Rect, buf: &mut Buffer, component: TextComponent) {
356    let indent = if let Some(meta) = component.meta_info().first() {
357        match meta.kind() {
358            WordType::MetaInfo(MetaData::HeadingLevel(e)) => e,
359            _ => 1,
360        }
361    } else {
362        1
363    };
364
365    let content: Vec<Span<'_>> = component
366        .content()
367        .iter()
368        .flatten()
369        .map(|c| style_heading(c, indent))
370        .collect();
371
372    let paragraph = match indent {
373        1 => Paragraph::new(Line::from(content))
374            .block(Block::default().style(Style::default().bg(color_config().heading_bg_color)))
375            .alignment(Alignment::Center),
376        _ => Paragraph::new(Line::from(content)),
377    };
378
379    paragraph.render(area, buf);
380}
381
382fn render_paragraph(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
383    let top = component
384        .scroll_offset()
385        .saturating_sub(component.y_offset());
386    let mut content = component.content_owned();
387    let content = match clip {
388        Clipping::Both => {
389            content.drain(0..top as usize);
390            content.drain(area.height as usize..);
391            content
392        }
393        Clipping::Upper => {
394            let len = content.len();
395            let height = area.height;
396            let offset = len - height as usize;
397            let mut content = content;
398            content.drain(0..offset);
399            content
400        }
401        Clipping::Lower => {
402            let mut content = content;
403            content.drain(area.height as usize..);
404            content
405        }
406        Clipping::None => content,
407    };
408
409    let lines = content
410        .iter()
411        .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
412        .collect::<Vec<_>>();
413
414    let paragraph = Paragraph::new(lines);
415
416    paragraph.render(area, buf);
417}
418
419fn render_list(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
420    let top = component
421        .scroll_offset()
422        .saturating_sub(component.y_offset());
423    let mut content = component.content_owned();
424    let content = match clip {
425        Clipping::Both => {
426            content.drain(0..top as usize);
427            content.drain(area.height as usize..);
428            content
429        }
430        Clipping::Upper => {
431            let len = content.len();
432            let height = area.height;
433            let offset = len - height as usize;
434            let mut content = content;
435            content.drain(0..offset);
436            content
437        }
438        Clipping::Lower => {
439            let mut content = content;
440            content.drain(area.height as usize..);
441            content
442        }
443        Clipping::None => content,
444    };
445    let content: Vec<ListItem<'_>> = content
446        .iter()
447        .map(|c| -> ListItem<'_> {
448            ListItem::new(Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
449        })
450        .collect();
451
452    let list = List::new(content);
453    list.render(area, buf);
454}
455
456fn render_code_block(area: Rect, buf: &mut Buffer, component: TextComponent, clip: Clipping) {
457    let mut content = component
458        .content()
459        .iter()
460        .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
461        .collect::<Vec<_>>();
462
463    match clip {
464        Clipping::Both => {
465            let top = component.scroll_offset() - component.y_offset();
466            content.drain(0..top as usize);
467            content.drain(area.height as usize..);
468        }
469        Clipping::Upper => {
470            let len = content.len();
471            let height = area.height;
472            let offset = len - height as usize;
473            // panic!("offset: {}, height: {}, len: {}", offset, height, len);
474            content.drain(0..offset);
475        }
476        Clipping::Lower => {
477            content.drain(area.height as usize..);
478        }
479        Clipping::None => (),
480    }
481
482    let block = Block::default().style(Style::default().bg(color_config().code_block_bg_color));
483
484    block.render(area, buf);
485
486    let area = Rect {
487        x: area.x + 1,
488        width: area.width - 1,
489        ..area
490    };
491
492    let paragraph = Paragraph::new(content);
493
494    paragraph.render(area, buf);
495}
496
497fn render_table(
498    area: Rect,
499    buf: &mut Buffer,
500    component: TextComponent,
501    clip: Clipping,
502    widths: Vec<u16>,
503    heights: Vec<u16>,
504) {
505    let column_count = widths.len();
506
507    if column_count == 0 {
508        Paragraph::new(Line::from("Malformed table").fg(Color::Red)).render(area, buf);
509        return;
510    }
511
512    let top = component
513        .scroll_offset()
514        .saturating_sub(component.y_offset());
515
516    let mut lines = build_table_lines(component.content(), &widths, &heights);
517
518    let lines = match clip {
519        Clipping::Both => {
520            lines.drain(0..top as usize);
521            lines.drain(area.height as usize..);
522            lines
523        }
524        Clipping::Upper => {
525            let offset = lines.len().saturating_sub(area.height as usize);
526            lines.drain(0..offset);
527            lines
528        }
529        Clipping::Lower => {
530            lines.drain(area.height as usize..);
531            lines
532        }
533        Clipping::None => lines,
534    };
535
536    Paragraph::new(lines).render(area, buf);
537}
538
539fn render_task(
540    area: Rect,
541    buf: &mut Buffer,
542    component: TextComponent,
543    clip: Clipping,
544    meta_info: &Word,
545) {
546    const CHECKBOX: &str = "✅ ";
547    const UNCHECKED: &str = "❌ ";
548
549    let checkbox = if meta_info.content() == "- [ ] " {
550        UNCHECKED
551    } else {
552        CHECKBOX
553    };
554
555    let paragraph = Paragraph::new(checkbox);
556
557    paragraph.render(area, buf);
558
559    let area = Rect {
560        x: area.x + 4,
561        width: area.width - 4,
562        ..area
563    };
564
565    let top = component
566        .scroll_offset()
567        .saturating_sub(component.y_offset());
568
569    let mut content = component.content_owned();
570
571    let content = match clip {
572        Clipping::Both => {
573            content.drain(0..top as usize);
574            content.drain(area.height as usize..);
575            content
576        }
577        Clipping::Upper => {
578            let len = content.len();
579            let height = area.height;
580            let offset = len - height as usize;
581            let mut content = content;
582            content.drain(0..offset);
583            content
584        }
585        Clipping::Lower => {
586            let mut content = content;
587            content.drain(area.height as usize..);
588            content
589        }
590        Clipping::None => content,
591    };
592
593    let lines = content
594        .iter()
595        .map(|c| Line::from(c.iter().map(style_word).collect::<Vec<_>>()))
596        .collect::<Vec<_>>();
597
598    let paragraph = Paragraph::new(lines);
599
600    paragraph.render(area, buf);
601}
602
603fn render_horizontal_separator(area: Rect, buf: &mut Buffer) {
604    let paragraph = Paragraph::new(Line::from(vec![Span::raw(
605        "\u{2014}".repeat(GENERAL_CONFIG.width.into()),
606    )]));
607
608    paragraph.render(area, buf);
609}