basalt_widgets/markdown/
view.rs

1//! # Markdown View Widget
2//!
3//! This module provides a widget called `MarkdownView` that can render Markdown content into
4//! terminal user interface (TUI) structures using the [`ratatui`](https://docs.rs/ratatui) crate.
5//! It integrates with a [`super::state::MarkdownViewState`] to manage scrolling and additional
6//! metadata.
7//!
8//! The module uses markdown parser [`basalt_core::markdown`] to produce
9//! [`basalt_core::markdown::Node`] values. Each node is converted to one or more
10//! [`ratatui::text::Line`] objects.
11//!
12//! Example of rendered output
13//!
14//! ██ Headings
15//!
16//! █ This is a heading 1
17//!
18//! ██ This is a heading 2
19//!
20//! ▓▓▓ This is a heading 3
21//!
22//! ▓▓▓▓ This is a heading 4
23//!
24//! ▓▓▓▓▓ This is a heading 5
25//!
26//! ░░░░░░ This is a heading 6
27//!
28//! ██ Quotes
29//!
30//! You can quote text by adding a > symbols before the text.
31//!
32//! ┃ Human beings face ever more complex and urgent problems, and their effectiveness in dealing with these problems is a matter that is critical to the stability and continued progress of society.
33//! ┃
34//! ┃ - Doug Engelbart, 1961
35//!
36//! ██ Bold, italics, highlights
37//!
38//! This line will not be bold
39//!
40//! \*\*This line will not be bold\*\*
41use ratatui::{
42    buffer::Buffer,
43    layout::Rect,
44    style::{Color, Modifier, Stylize},
45    text::{Line, Span},
46    widgets::{
47        self, Block, BorderType, Paragraph, ScrollbarOrientation, StatefulWidget,
48        StatefulWidgetRef, Widget,
49    },
50};
51
52use basalt_core::markdown::{self, HeadingLevel, ItemKind};
53
54use super::state::MarkdownViewState;
55
56/// A widget for rendering markdown text using [`MarkdownViewState`].
57///
58/// # Example
59///
60/// ```rust
61/// use basalt_core::markdown;
62/// use basalt_widgets::markdown::{MarkdownViewState, MarkdownView};
63/// use ratatui::prelude::*;
64/// use ratatui::widgets::StatefulWidgetRef;
65///
66/// let text = "# Hello, world!\nThis is a test.";
67/// let mut state = MarkdownViewState::new(text);
68///
69/// let area = Rect::new(0, 0, 20, 10);
70/// let mut buffer = Buffer::empty(area);
71///
72/// MarkdownView.render_ref(area, &mut buffer, &mut state);
73///
74/// let expected = [
75///   "╭──────────────────▲",
76///   "│█ Hello, world!   █",
77///   "│                  █",
78///   "│This is a test.   █",
79///   "│                  █",
80///   "│                  █",
81///   "│                  █",
82///   "│                  ║",
83///   "│                  ║",
84///   "╰──────────────────▼",
85/// ];
86///
87/// // FIXME: Take styles into account
88/// // assert_eq!(buffer, Buffer::with_lines(expected));
89/// ```
90#[derive(Clone, Debug, PartialEq)]
91pub struct MarkdownView;
92
93impl MarkdownView {
94    fn heading(level: HeadingLevel, content: Vec<Span>) -> Line {
95        let prefix = match level {
96            HeadingLevel::H1 => Span::from("█ ").blue(),
97            HeadingLevel::H2 => Span::from("██ ").cyan(),
98            HeadingLevel::H3 => Span::from("▓▓▓ ").green(),
99            HeadingLevel::H4 => Span::from("▓▓▓▓ ").yellow(),
100            HeadingLevel::H5 => Span::from("▓▓▓▓▓ ").red(),
101            HeadingLevel::H6 => Span::from("░░░░░░ ").red(),
102        };
103        Line::from([prefix].into_iter().chain(content).collect::<Vec<_>>()).bold()
104    }
105
106    fn item<'a>(kind: Option<ItemKind>, content: Vec<Span<'a>>, prefix: Span<'a>) -> Line<'a> {
107        match kind {
108            Some(kind) => match kind {
109                ItemKind::Unchecked => Line::from(
110                    [prefix, "󰄱 ".black()]
111                        .into_iter()
112                        .chain(content)
113                        .collect::<Vec<_>>(),
114                ),
115                ItemKind::Checked => Line::from(
116                    [prefix, "󰄲 ".magenta()]
117                        .into_iter()
118                        .chain(content)
119                        .collect::<Vec<_>>(),
120                ),
121                ItemKind::HardChecked => Line::from(
122                    [prefix, "󰄲 ".magenta()]
123                        .into_iter()
124                        .chain(content)
125                        .collect::<Vec<_>>(),
126                )
127                .black()
128                .add_modifier(Modifier::CROSSED_OUT),
129                ItemKind::Ordered(num) => Line::from(
130                    [prefix, num.to_string().black(), ". ".into()]
131                        .into_iter()
132                        .chain(content)
133                        .collect::<Vec<_>>(),
134                ),
135                ItemKind::Unordered => Line::from(
136                    [prefix, "- ".black()]
137                        .into_iter()
138                        .chain(content)
139                        .collect::<Vec<_>>(),
140                ),
141            },
142            None => Line::from(
143                [prefix, "- ".black()]
144                    .into_iter()
145                    .chain(content)
146                    .collect::<Vec<_>>(),
147            ),
148        }
149    }
150
151    fn text_to_spans<'a>(text: markdown::Text) -> Vec<Span<'a>> {
152        text.into_iter()
153            .map(|text| Span::from(text.content))
154            .collect()
155    }
156
157    fn code_block<'a>(text: markdown::Text) -> Vec<Line<'a>> {
158        text.into_iter()
159            .flat_map(|text| {
160                text.content
161                    .clone()
162                    .split("\n")
163                    .map(String::from)
164                    .collect::<Vec<String>>()
165            })
166            .map(|text| Line::from(text).red().bg(Color::Rgb(10, 10, 10)))
167            .collect()
168    }
169
170    fn render_markdown<'a>(node: markdown::Node, prefix: Span<'a>) -> Vec<Line<'a>> {
171        match node.markdown_node {
172            markdown::MarkdownNode::Paragraph { text } => {
173                let mut spans = MarkdownView::text_to_spans(text);
174                spans.insert(0, prefix.clone());
175                vec![spans.into(), Line::from(prefix)]
176            }
177            markdown::MarkdownNode::Heading { level, text } => [
178                MarkdownView::heading(level, MarkdownView::text_to_spans(text)),
179                Line::default(),
180            ]
181            .to_vec(),
182            markdown::MarkdownNode::Item { kind, text } => [
183                MarkdownView::item(kind, MarkdownView::text_to_spans(text), prefix),
184                Line::default(),
185            ]
186            .to_vec(),
187            // TODO: Add lang support and syntax highlighting
188            markdown::MarkdownNode::CodeBlock { text, .. } => {
189                let mut lines = MarkdownView::code_block(text);
190                lines.insert(0, Line::default());
191                lines
192            }
193            // TODO: Support callout block quote types
194            markdown::MarkdownNode::BlockQuote { nodes, .. } => {
195                let mut lines = nodes
196                    .into_iter()
197                    .flat_map(|child| {
198                        MarkdownView::render_markdown(child, Span::from("┃ ").magenta())
199                    })
200                    .map(|line| line.dark_gray())
201                    .collect::<Vec<Line<'a>>>();
202
203                lines.push(Line::default());
204
205                lines
206            }
207        }
208    }
209}
210
211impl StatefulWidgetRef for MarkdownView {
212    type State = MarkdownViewState;
213
214    fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
215        let nodes = markdown::from_str(&state.text)
216            .into_iter()
217            .flat_map(|node| MarkdownView::render_markdown(node, Span::default()))
218            .collect::<Vec<Line<'_>>>();
219
220        let mut scroll_state = state.scrollbar.state.content_length(nodes.len());
221
222        let root_node = Paragraph::new(nodes)
223            .block(Block::bordered().border_type(BorderType::Rounded))
224            .scroll((state.scrollbar.position as u16, 0));
225
226        Widget::render(root_node, area, buf);
227
228        StatefulWidget::render(
229            widgets::Scrollbar::new(ScrollbarOrientation::VerticalRight),
230            area,
231            buf,
232            &mut scroll_state,
233        );
234    }
235}