Skip to main content

md_tui/pages/
markdown_renderer.rs

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