magical markdown slides
1use ratatui::{
2 style::{Modifier, Style},
3 text::{Line, Span, Text},
4};
5use slides_core::{
6 slide::{Block, CodeBlock, List, Table, TextSpan, TextStyle},
7 theme::ThemeColors,
8};
9
10/// Render a slide's blocks into ratatui Text
11///
12/// Converts slide blocks into styled ratatui text with theming applied.
13pub fn render_slide_content(blocks: &[Block], theme: &ThemeColors) -> Text<'static> {
14 let mut lines = Vec::new();
15
16 for block in blocks {
17 match block {
18 Block::Heading { level, spans } => render_heading(*level, spans, theme, &mut lines),
19 Block::Paragraph { spans } => render_paragraph(spans, theme, &mut lines),
20 Block::Code(code_block) => render_code_block(code_block, theme, &mut lines),
21 Block::List(list) => render_list(list, theme, &mut lines, 0),
22 Block::Rule => render_rule(theme, &mut lines),
23 Block::BlockQuote { blocks } => render_blockquote(blocks, theme, &mut lines),
24 Block::Table(table) => render_table(table, theme, &mut lines),
25 }
26
27 lines.push(Line::raw(""));
28 }
29
30 Text::from(lines)
31}
32
33/// Get heading prefix
34fn get_prefix(level: u8) -> &'static str {
35 match level {
36 1 => "# ",
37 2 => "## ",
38 3 => "### ",
39 4 => "#### ",
40 5 => "##### ",
41 _ => "###### ",
42 }
43}
44
45/// Render a heading with size based on level
46fn render_heading(level: u8, spans: &[TextSpan], theme: &ThemeColors, lines: &mut Vec<Line<'static>>) {
47 let prefix = get_prefix(level);
48 let heading_style = to_ratatui_style(&theme.heading);
49 let mut line_spans = vec![Span::styled(prefix.to_string(), heading_style)];
50
51 for span in spans {
52 line_spans.push(create_span(span, theme, true));
53 }
54
55 lines.push(Line::from(line_spans));
56}
57
58/// Render a paragraph with styled text spans
59fn render_paragraph(spans: &[TextSpan], theme: &ThemeColors, lines: &mut Vec<Line<'static>>) {
60 let line_spans: Vec<_> = spans.iter().map(|span| create_span(span, theme, false)).collect();
61 lines.push(Line::from(line_spans));
62}
63
64/// Render a code block with monospace styling
65fn render_code_block(code: &CodeBlock, theme: &ThemeColors, lines: &mut Vec<Line<'static>>) {
66 let fence_style = to_ratatui_style(&theme.code_fence);
67 let code_style = to_ratatui_style(&theme.code);
68
69 if let Some(lang) = &code.language {
70 lines.push(Line::from(Span::styled(format!("```{}", lang), fence_style)));
71 } else {
72 lines.push(Line::from(Span::styled("```".to_string(), fence_style)));
73 }
74
75 for line in code.code.lines() {
76 lines.push(Line::from(Span::styled(line.to_string(), code_style)));
77 }
78
79 lines.push(Line::from(Span::styled("```".to_string(), fence_style)));
80}
81
82/// Render a list with bullets or numbers
83fn render_list(list: &List, theme: &ThemeColors, lines: &mut Vec<Line<'static>>, indent: usize) {
84 let marker_style = to_ratatui_style(&theme.list_marker);
85
86 for (idx, item) in list.items.iter().enumerate() {
87 let prefix = if list.ordered {
88 format!("{}{}. ", " ".repeat(indent), idx + 1)
89 } else {
90 format!("{}• ", " ".repeat(indent))
91 };
92
93 let mut line_spans = vec![Span::styled(prefix, marker_style)];
94
95 for span in &item.spans {
96 line_spans.push(create_span(span, theme, false));
97 }
98
99 lines.push(Line::from(line_spans));
100
101 if let Some(nested) = &item.nested {
102 render_list(nested, theme, lines, indent + 1);
103 }
104 }
105}
106
107/// Render a horizontal rule
108fn render_rule(theme: &ThemeColors, lines: &mut Vec<Line<'static>>) {
109 let rule_style = to_ratatui_style(&theme.rule);
110 let rule = "─".repeat(60);
111 lines.push(Line::from(Span::styled(rule, rule_style)));
112}
113
114/// Render a blockquote with indentation
115fn render_blockquote(blocks: &[Block], theme: &ThemeColors, lines: &mut Vec<Line<'static>>) {
116 let border_style = to_ratatui_style(&theme.blockquote_border);
117
118 for block in blocks {
119 match block {
120 Block::Paragraph { spans } => {
121 let mut line_spans = vec![Span::styled("│ ".to_string(), border_style)];
122
123 for span in spans {
124 line_spans.push(create_span(span, theme, false));
125 }
126
127 lines.push(Line::from(line_spans));
128 }
129 _ => {}
130 }
131 }
132}
133
134/// Render a table with basic formatting
135fn render_table(table: &Table, theme: &ThemeColors, lines: &mut Vec<Line<'static>>) {
136 let border_style = to_ratatui_style(&theme.table_border);
137
138 if !table.headers.is_empty() {
139 let mut header_line = Vec::new();
140 for (idx, header) in table.headers.iter().enumerate() {
141 if idx > 0 {
142 header_line.push(Span::styled(" │ ".to_string(), border_style));
143 }
144 for span in header {
145 header_line.push(create_span(span, theme, true));
146 }
147 }
148 lines.push(Line::from(header_line));
149
150 let separator = "─".repeat(60);
151 lines.push(Line::from(Span::styled(separator, border_style)));
152 }
153
154 for row in &table.rows {
155 let mut row_line = Vec::new();
156 for (idx, cell) in row.iter().enumerate() {
157 if idx > 0 {
158 row_line.push(Span::styled(" │ ".to_string(), border_style));
159 }
160 for span in cell {
161 row_line.push(create_span(span, theme, false));
162 }
163 }
164 lines.push(Line::from(row_line));
165 }
166}
167
168/// Create a styled span from a TextSpan
169fn create_span(text_span: &TextSpan, theme: &ThemeColors, is_heading: bool) -> Span<'static> {
170 let style = apply_theme_style(theme, &text_span.style, is_heading);
171 Span::styled(text_span.text.clone(), style)
172}
173
174/// Apply theme colors and text styling
175fn apply_theme_style(theme: &ThemeColors, text_style: &TextStyle, is_heading: bool) -> Style {
176 let mut style = if is_heading {
177 to_ratatui_style(&theme.heading)
178 } else if text_style.code {
179 to_ratatui_style(&theme.code)
180 } else {
181 to_ratatui_style(&theme.body)
182 };
183
184 if text_style.bold {
185 style = style.add_modifier(Modifier::BOLD);
186 }
187 if text_style.italic {
188 style = style.add_modifier(Modifier::ITALIC);
189 }
190 if text_style.strikethrough {
191 style = style.add_modifier(Modifier::CROSSED_OUT);
192 }
193
194 style
195}
196
197/// Convert owo-colors Style to ratatui Style
198///
199/// Since owo-colors Style is opaque, we return a default ratatui style.
200/// The theme provides semantic meaning; actual visual styling is defined here.
201fn to_ratatui_style(_owo_style: &owo_colors::Style) -> Style {
202 Style::default()
203}
204
205#[cfg(test)]
206mod tests {
207 use slides_core::slide::ListItem;
208
209 use super::*;
210
211 #[test]
212 fn render_heading_basic() {
213 let blocks = vec![Block::Heading { level: 1, spans: vec![TextSpan::plain("Test Heading")] }];
214 let theme = ThemeColors::default();
215 let text = render_slide_content(&blocks, &theme);
216 assert!(!text.lines.is_empty());
217 }
218
219 #[test]
220 fn render_paragraph_basic() {
221 let blocks = vec![Block::Paragraph { spans: vec![TextSpan::plain("Test paragraph")] }];
222 let theme = ThemeColors::default();
223 let text = render_slide_content(&blocks, &theme);
224 assert!(!text.lines.is_empty());
225 }
226
227 #[test]
228 fn render_code_block() {
229 let blocks = vec![Block::Code(CodeBlock::with_language("rust", "fn main() {}"))];
230 let theme = ThemeColors::default();
231 let text = render_slide_content(&blocks, &theme);
232 assert!(text.lines.len() > 2);
233 }
234
235 #[test]
236 fn render_list_unordered() {
237 let list = List {
238 ordered: false,
239 items: vec![
240 ListItem { spans: vec![TextSpan::plain("Item 1")], nested: None },
241 ListItem { spans: vec![TextSpan::plain("Item 2")], nested: None },
242 ],
243 };
244 let blocks = vec![Block::List(list)];
245 let theme = ThemeColors::default();
246 let text = render_slide_content(&blocks, &theme);
247 assert!(text.lines.len() >= 2);
248 }
249
250 #[test]
251 fn render_styled_text() {
252 let blocks = vec![Block::Paragraph {
253 spans: vec![
254 TextSpan::bold("Bold"),
255 TextSpan::plain(" "),
256 TextSpan::italic("Italic"),
257 TextSpan::plain(" "),
258 TextSpan::code("code"),
259 ],
260 }];
261 let theme = ThemeColors::default();
262 let text = render_slide_content(&blocks, &theme);
263 assert!(!text.lines.is_empty());
264 }
265}