basalt_tui/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, Padding, Paragraph, ScrollbarOrientation, StatefulWidget,
48        StatefulWidgetRef, Widget,
49    },
50};
51
52use super::parser;
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: parser::HeadingLevel, content: Vec<Span>) -> Line {
95        let prefix = match level {
96            parser::HeadingLevel::H1 => Span::from("█ ").blue(),
97            parser::HeadingLevel::H2 => Span::from("██ ").cyan(),
98            parser::HeadingLevel::H3 => Span::from("▓▓▓ ").green(),
99            parser::HeadingLevel::H4 => Span::from("▓▓▓▓ ").yellow(),
100            parser::HeadingLevel::H5 => Span::from("▓▓▓▓▓ ").red(),
101            parser::HeadingLevel::H6 => Span::from("░░░░░░ ").red(),
102        };
103        Line::from([prefix].into_iter().chain(content).collect::<Vec<_>>()).bold()
104    }
105
106    fn task<'a>(
107        kind: parser::TaskListItemKind,
108        content: Vec<Span<'a>>,
109        prefix: Span<'a>,
110    ) -> Line<'a> {
111        match kind {
112            parser::TaskListItemKind::Unchecked => Line::from(
113                [prefix, "□ ".black()]
114                    .into_iter()
115                    .chain(content)
116                    .collect::<Vec<_>>(),
117            ),
118            parser::TaskListItemKind::Checked => Line::from(
119                [prefix, "■ ".magenta()]
120                    .into_iter()
121                    .chain(content)
122                    .collect::<Vec<_>>(),
123            )
124            .black()
125            .add_modifier(Modifier::CROSSED_OUT),
126            parser::TaskListItemKind::LooselyChecked => Line::from(
127                [prefix, "■ ".magenta()]
128                    .into_iter()
129                    .chain(content)
130                    .collect::<Vec<_>>(),
131            ),
132        }
133    }
134
135    fn item<'a>(kind: parser::ItemKind, content: Vec<Span<'a>>, prefix: Span<'a>) -> Line<'a> {
136        match kind {
137            parser::ItemKind::Ordered(num) => Line::from(
138                [prefix, num.to_string().black(), ". ".into()]
139                    .into_iter()
140                    .chain(content)
141                    .collect::<Vec<_>>(),
142            ),
143            parser::ItemKind::Unordered => Line::from(
144                [prefix, "- ".black()]
145                    .into_iter()
146                    .chain(content)
147                    .collect::<Vec<_>>(),
148            ),
149        }
150    }
151
152    fn text_to_spans<'a>(text: parser::Text) -> Vec<Span<'a>> {
153        text.into_iter()
154            .map(|text| Span::from(text.content))
155            .collect()
156    }
157
158    fn code_block<'a>(text: parser::Text, width: usize) -> Vec<Line<'a>> {
159        text.into_iter()
160            .flat_map(|text| {
161                text.content
162                    .clone()
163                    .split("\n")
164                    .map(|line| {
165                        format!(
166                            " {} {}",
167                            line,
168                            (line.len()..width).map(|_| " ").collect::<String>()
169                        )
170                    })
171                    .collect::<Vec<String>>()
172            })
173            .map(|text| Line::from(text).bold().bg(Color::Black))
174            .collect()
175    }
176
177    fn wrap_with_prefix(text: String, width: usize, prefix: Span) -> Vec<Line> {
178        let options =
179            textwrap::Options::new(width.saturating_sub(prefix.width())).break_words(false);
180
181        textwrap::wrap(&text, &options)
182            .into_iter()
183            .map(|wrapped_line| {
184                Line::from([prefix.clone(), Span::from(wrapped_line.to_string())].to_vec())
185            })
186            .collect()
187    }
188
189    fn render_markdown<'a>(node: parser::Node, area: Rect, prefix: Span<'a>) -> Vec<Line<'a>> {
190        match node.markdown_node {
191            parser::MarkdownNode::Paragraph { text } => {
192                MarkdownView::wrap_with_prefix(text.into(), area.width.into(), prefix.clone())
193                    .into_iter()
194                    .chain([Line::from(prefix)])
195                    .collect::<Vec<_>>()
196            }
197            parser::MarkdownNode::Heading { level, text } => [
198                MarkdownView::heading(level, MarkdownView::text_to_spans(text)),
199                Line::default(),
200            ]
201            .to_vec(),
202            parser::MarkdownNode::Item { text } => [MarkdownView::item(
203                parser::ItemKind::Unordered,
204                MarkdownView::text_to_spans(text),
205                prefix,
206            )]
207            .to_vec(),
208            parser::MarkdownNode::TaskListItem { kind, text } => [MarkdownView::task(
209                kind,
210                MarkdownView::text_to_spans(text),
211                prefix,
212            )]
213            .to_vec(),
214            // TODO: Add lang support and syntax highlighting
215            parser::MarkdownNode::CodeBlock { text, .. } => {
216                [Line::from((0..area.width).map(|_| " ").collect::<String>()).bg(Color::Black)]
217                    .into_iter()
218                    .chain(MarkdownView::code_block(text, area.width.into()))
219                    .chain([Line::default()])
220                    .collect::<Vec<_>>()
221            }
222            parser::MarkdownNode::List { nodes, kind } => nodes
223                .into_iter()
224                .enumerate()
225                .flat_map(|(i, child)| {
226                    let parser::MarkdownNode::Item { text } = child.markdown_node else {
227                        return MarkdownView::render_markdown(child, area, prefix.clone());
228                    };
229
230                    let item = match kind {
231                        parser::ListKind::Ordered(start) => MarkdownView::item(
232                            parser::ItemKind::Ordered(start + i as u64),
233                            MarkdownView::text_to_spans(text),
234                            prefix.clone(),
235                        ),
236                        _ => MarkdownView::item(
237                            parser::ItemKind::Unordered,
238                            MarkdownView::text_to_spans(text),
239                            prefix.clone(),
240                        ),
241                    };
242
243                    [item].to_vec()
244                })
245                .chain([Line::default()])
246                .collect::<Vec<Line<'a>>>(),
247
248            // TODO: Support callout block quote types
249            parser::MarkdownNode::BlockQuote { nodes, .. } => nodes
250                .into_iter()
251                .flat_map(|child| {
252                    MarkdownView::render_markdown(child, area, Span::from("┃ ").magenta())
253                        .into_iter()
254                        .collect::<Vec<_>>()
255                })
256                .map(|line| line.dark_gray())
257                .chain([Line::default()])
258                .collect::<Vec<Line<'a>>>(),
259        }
260    }
261}
262
263impl StatefulWidgetRef for MarkdownView {
264    type State = MarkdownViewState;
265
266    fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
267        let block = Block::bordered()
268            .border_type(BorderType::Rounded)
269            .padding(Padding::horizontal(1));
270
271        let nodes = parser::from_str(&state.text)
272            .into_iter()
273            .flat_map(|node| {
274                MarkdownView::render_markdown(node, block.inner(area), Span::default())
275            })
276            .collect::<Vec<Line<'_>>>();
277
278        let mut scroll_state = state.scrollbar.state.content_length(nodes.len());
279
280        let root_node = Paragraph::new(nodes)
281            .block(block)
282            .scroll((state.scrollbar.position as u16, 0));
283
284        Widget::render(root_node, area, buf);
285
286        StatefulWidget::render(
287            widgets::Scrollbar::new(ScrollbarOrientation::VerticalRight),
288            area,
289            buf,
290            &mut scroll_state,
291        );
292    }
293}