magical markdown slides
1use lantern_core::{
2 highlighter,
3 slide::{Block, CodeBlock, List, Table, TextSpan, TextStyle},
4 theme::ThemeColors,
5};
6use ratatui::{
7 style::{Modifier, Style},
8 text::{Line, Span, Text},
9};
10use unicode_width::UnicodeWidthChar;
11
12/// Image information extracted from blocks
13pub struct ImageInfo {
14 pub path: String,
15 pub alt: String,
16}
17
18/// Render a slide's blocks and extract images
19///
20/// Returns both the text content and a list of images found in the blocks.
21pub fn render_slide_with_images(blocks: &[Block], theme: &ThemeColors) -> (Text<'static>, Vec<ImageInfo>) {
22 let mut lines = Vec::new();
23 let mut images = Vec::new();
24
25 for block in blocks {
26 match block {
27 Block::Heading { level, spans } => render_heading(*level, spans, theme, &mut lines),
28 Block::Paragraph { spans } => render_paragraph(spans, theme, &mut lines),
29 Block::Code(code_block) => render_code_block(code_block, theme, &mut lines),
30 Block::List(list) => render_list(list, theme, &mut lines, 0),
31 Block::Rule => render_rule(theme, &mut lines),
32 Block::BlockQuote { blocks } => render_blockquote(blocks, theme, &mut lines),
33 Block::Table(table) => render_table(table, theme, &mut lines),
34 Block::Admonition(admonition) => render_admonition(admonition, theme, &mut lines),
35 Block::Image { path, alt } => images.push(ImageInfo { path: path.clone(), alt: alt.clone() }),
36 }
37
38 lines.push(Line::raw(""));
39 }
40
41 (Text::from(lines), images)
42}
43
44/// Render a slide's blocks into ratatui Text
45///
46/// Converts slide blocks into styled ratatui text with theming applied.
47pub fn render_slide_content(blocks: &[Block], theme: &ThemeColors) -> Text<'static> {
48 let mut lines = Vec::new();
49
50 for block in blocks {
51 match block {
52 Block::Heading { level, spans } => render_heading(*level, spans, theme, &mut lines),
53 Block::Paragraph { spans } => render_paragraph(spans, theme, &mut lines),
54 Block::Code(code_block) => render_code_block(code_block, theme, &mut lines),
55 Block::List(list) => render_list(list, theme, &mut lines, 0),
56 Block::Rule => render_rule(theme, &mut lines),
57 Block::BlockQuote { blocks } => render_blockquote(blocks, theme, &mut lines),
58 Block::Table(table) => render_table(table, theme, &mut lines),
59 Block::Admonition(admonition) => render_admonition(admonition, theme, &mut lines),
60 // Images are handled separately when using render_slide_with_images
61 Block::Image { .. } => {}
62 }
63
64 lines.push(Line::raw(""));
65 }
66
67 Text::from(lines)
68}
69
70/// Get heading prefix using Unicode block symbols
71/// 1. (*h1*) Large block / heavy fill (`U+2589`)
72/// 2. (*h2*) Dark shade (`U+2593`)
73/// 3. (*h3*) Medium shade (`U+2592`)
74/// 4. (*h4*) Light shade (`U+2591`)
75/// 5. (*h5*) Left half block (`U+258C`)
76/// 6. (*h6*) Left half block (`U+258C`)
77fn get_prefix(level: u8) -> &'static str {
78 match level {
79 1 => "▉ ",
80 2 => "▓ ",
81 3 => "▒ ",
82 4 => "░ ",
83 5 => "▌ ",
84 _ => "▌ ",
85 }
86}
87
88/// Render a heading with size based on level
89fn render_heading(level: u8, spans: &[TextSpan], theme: &ThemeColors, lines: &mut Vec<Line<'static>>) {
90 let prefix = get_prefix(level);
91 let heading_style = to_ratatui_style(&theme.heading, theme.heading_bold);
92 let mut line_spans = vec![Span::styled(prefix.to_string(), heading_style)];
93
94 for span in spans {
95 line_spans.push(create_span(span, theme, true));
96 }
97
98 lines.push(Line::from(line_spans));
99}
100
101/// Render a paragraph with styled text spans
102fn render_paragraph(spans: &[TextSpan], theme: &ThemeColors, lines: &mut Vec<Line<'static>>) {
103 let line_spans: Vec<_> = spans.iter().map(|span| create_span(span, theme, false)).collect();
104 lines.push(Line::from(line_spans));
105}
106
107/// Render a code block with syntax highlighting
108fn render_code_block(code: &CodeBlock, theme: &ThemeColors, lines: &mut Vec<Line<'static>>) {
109 let fence_style = to_ratatui_style(&theme.code_fence, false);
110
111 if let Some(lang) = &code.language {
112 lines.push(Line::from(Span::styled(format!("```{lang}"), fence_style)));
113 } else {
114 lines.push(Line::from(Span::styled("```".to_string(), fence_style)));
115 }
116
117 let highlighted_lines = highlighter::highlight_code(&code.code, code.language.as_deref(), theme);
118
119 for tokens in highlighted_lines {
120 let mut line_spans = Vec::new();
121 for token in tokens {
122 let token_style = to_ratatui_style(&token.color, false);
123 line_spans.push(Span::styled(token.text, token_style));
124 }
125 lines.push(Line::from(line_spans));
126 }
127
128 lines.push(Line::from(Span::styled("```".to_string(), fence_style)));
129}
130
131/// Render a list with bullets or numbers
132fn render_list(list: &List, theme: &ThemeColors, lines: &mut Vec<Line<'static>>, indent: usize) {
133 let marker_style = to_ratatui_style(&theme.list_marker, false);
134
135 for (idx, item) in list.items.iter().enumerate() {
136 let prefix = if list.ordered {
137 format!("{}{}. ", " ".repeat(indent), idx + 1)
138 } else {
139 format!("{}• ", " ".repeat(indent))
140 };
141
142 let mut line_spans = vec![Span::styled(prefix, marker_style)];
143
144 for span in &item.spans {
145 line_spans.push(create_span(span, theme, false));
146 }
147
148 lines.push(Line::from(line_spans));
149
150 if let Some(nested) = &item.nested {
151 render_list(nested, theme, lines, indent + 1);
152 }
153 }
154}
155
156/// Render a horizontal rule
157fn render_rule(theme: &ThemeColors, lines: &mut Vec<Line<'static>>) {
158 let rule_style = to_ratatui_style(&theme.rule, false);
159 lines.push(Line::from(Span::styled("─".repeat(60), rule_style)));
160}
161
162/// Render a blockquote with indentation
163fn render_blockquote(blocks: &[Block], theme: &ThemeColors, lines: &mut Vec<Line<'static>>) {
164 let border_style = to_ratatui_style(&theme.blockquote_border, false);
165
166 for block in blocks {
167 if let Block::Paragraph { spans } = block {
168 let mut line_spans = vec![Span::styled("│ ".to_string(), border_style)];
169
170 for span in spans {
171 line_spans.push(create_span(span, theme, false));
172 }
173
174 lines.push(Line::from(line_spans));
175 }
176 }
177}
178
179/// Render an admonition with colored border and icon
180fn render_admonition(
181 admonition: &lantern_core::slide::Admonition, theme: &ThemeColors, lines: &mut Vec<Line<'static>>,
182) {
183 use lantern_core::slide::AdmonitionType;
184
185 let (icon, color, default_title) = match admonition.admonition_type {
186 AdmonitionType::Note => ("\u{24D8}", &theme.admonition_note, "Note"),
187 AdmonitionType::Tip => ("\u{1F4A1}", &theme.admonition_tip, "Tip"),
188 AdmonitionType::Important => ("\u{2757}", &theme.admonition_tip, "Important"),
189 AdmonitionType::Warning => ("\u{26A0}", &theme.admonition_warning, "Warning"),
190 AdmonitionType::Caution => ("\u{26A0}", &theme.admonition_warning, "Caution"),
191 AdmonitionType::Danger => ("\u{26D4}", &theme.admonition_danger, "Danger"),
192 AdmonitionType::Error => ("\u{2717}", &theme.admonition_danger, "Error"),
193 AdmonitionType::Info => ("\u{24D8}", &theme.admonition_info, "Info"),
194 AdmonitionType::Success => ("\u{2713}", &theme.admonition_success, "Success"),
195 AdmonitionType::Question => ("?", &theme.admonition_info, "Question"),
196 AdmonitionType::Example => ("\u{25B8}", &theme.admonition_success, "Example"),
197 AdmonitionType::Quote => ("\u{201C}", &theme.admonition_info, "Quote"),
198 AdmonitionType::Abstract => ("\u{00A7}", &theme.admonition_note, "Abstract"),
199 AdmonitionType::Todo => ("\u{2610}", &theme.admonition_info, "Todo"),
200 AdmonitionType::Bug => ("\u{1F41B}", &theme.admonition_danger, "Bug"),
201 AdmonitionType::Failure => ("\u{2717}", &theme.admonition_danger, "Failure"),
202 };
203
204 let title = admonition.title.as_deref().unwrap_or(default_title);
205 let color_style = to_ratatui_style(color, false);
206 let bold_color_style = to_ratatui_style(color, true);
207
208 let top_border = format!("\u{256D}{}\u{256E}", "\u{2500}".repeat(58));
209 lines.push(Line::from(Span::styled(top_border, color_style)));
210
211 let icon_display_width = icon.chars().next().and_then(|c| c.width()).unwrap_or(1);
212
213 let title_line = vec![
214 Span::styled("\u{2502} ".to_string(), color_style),
215 Span::raw(format!("{icon} ")),
216 Span::styled(title.to_string(), bold_color_style),
217 Span::styled(
218 " ".repeat(56_usize.saturating_sub(icon_display_width + 1 + title.len())),
219 color_style,
220 ),
221 Span::styled(" \u{2502}".to_string(), color_style),
222 ];
223 lines.push(Line::from(title_line));
224
225 if !admonition.blocks.is_empty() {
226 let separator = format!("\u{251C}{}\u{2524}", "\u{2500}".repeat(58));
227 lines.push(Line::from(Span::styled(separator, color_style)));
228
229 for block in &admonition.blocks {
230 if let Block::Paragraph { spans } = block {
231 let text: String = spans.iter().map(|s| s.text.as_str()).collect();
232 let words: Vec<&str> = text.split_whitespace().collect();
233 let content_width = 56; // 60 total - 2 for borders - 2 for spaces
234
235 let mut current_line = String::new();
236 for word in words {
237 if current_line.is_empty() {
238 current_line = word.to_string();
239 } else if current_line.len() + 1 + word.len() <= content_width {
240 current_line.push(' ');
241 current_line.push_str(word);
242 } else {
243 let mut line_spans = vec![Span::styled("\u{2502} ".to_string(), color_style)];
244 line_spans.push(Span::raw(current_line.clone()));
245 let padding = content_width.saturating_sub(current_line.len());
246 line_spans.push(Span::raw(" ".repeat(padding)));
247 line_spans.push(Span::styled(" \u{2502}".to_string(), color_style));
248 lines.push(Line::from(line_spans));
249 current_line = word.to_string();
250 }
251 }
252
253 if !current_line.is_empty() {
254 let mut line_spans = vec![Span::styled("\u{2502} ".to_string(), color_style)];
255 line_spans.push(Span::raw(current_line.clone()));
256 let padding = content_width.saturating_sub(current_line.len());
257 line_spans.push(Span::raw(" ".repeat(padding)));
258 line_spans.push(Span::styled(" \u{2502}".to_string(), color_style));
259 lines.push(Line::from(line_spans));
260 }
261 }
262 }
263 }
264
265 let bottom_border = format!("\u{2570}{}\u{256F}", "\u{2500}".repeat(58));
266 lines.push(Line::from(Span::styled(bottom_border, color_style)));
267}
268
269/// Render a table with basic formatting
270fn render_table(table: &Table, theme: &ThemeColors, lines: &mut Vec<Line<'static>>) {
271 let border_style = to_ratatui_style(&theme.table_border, false);
272
273 if !table.headers.is_empty() {
274 let mut header_line = Vec::new();
275 for (idx, header) in table.headers.iter().enumerate() {
276 if idx > 0 {
277 header_line.push(Span::styled(" │ ".to_string(), border_style));
278 }
279 for span in header {
280 header_line.push(create_span(span, theme, true));
281 }
282 }
283 lines.push(Line::from(header_line));
284
285 let separator = "─".repeat(60);
286 lines.push(Line::from(Span::styled(separator, border_style)));
287 }
288
289 for row in &table.rows {
290 let mut row_line = Vec::new();
291 for (idx, cell) in row.iter().enumerate() {
292 if idx > 0 {
293 row_line.push(Span::styled(" │ ".to_string(), border_style));
294 }
295 for span in cell {
296 row_line.push(create_span(span, theme, false));
297 }
298 }
299 lines.push(Line::from(row_line));
300 }
301}
302
303/// Create a styled span from a TextSpan
304fn create_span(text_span: &TextSpan, theme: &ThemeColors, is_heading: bool) -> Span<'static> {
305 let style = apply_theme_style(theme, &text_span.style, is_heading);
306 Span::styled(text_span.text.clone(), style)
307}
308
309/// Apply theme colors and text styling
310fn apply_theme_style(theme: &ThemeColors, text_style: &TextStyle, is_heading: bool) -> Style {
311 let mut style = if is_heading {
312 to_ratatui_style(&theme.heading, theme.heading_bold)
313 } else if text_style.code {
314 to_ratatui_style(&theme.code, false)
315 } else {
316 to_ratatui_style(&theme.body, false)
317 };
318
319 if text_style.bold {
320 style = style.add_modifier(Modifier::BOLD);
321 }
322 if text_style.italic {
323 style = style.add_modifier(Modifier::ITALIC);
324 }
325 if text_style.strikethrough {
326 style = style.add_modifier(Modifier::CROSSED_OUT);
327 }
328
329 style
330}
331
332/// Convert theme Color to ratatui Style with RGB colors
333fn to_ratatui_style(color: &lantern_core::theme::Color, bold: bool) -> Style {
334 let mut style = Style::default().fg(ratatui::style::Color::Rgb(color.r, color.g, color.b));
335
336 if bold {
337 style = style.add_modifier(Modifier::BOLD);
338 }
339
340 style
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 use lantern_core::slide::ListItem;
348 use lantern_core::theme::Color;
349
350 #[test]
351 fn render_heading_basic() {
352 let blocks = vec![Block::Heading { level: 1, spans: vec![TextSpan::plain("Test Heading")] }];
353 let theme = ThemeColors::default();
354 let text = render_slide_content(&blocks, &theme);
355 assert!(!text.lines.is_empty());
356 }
357
358 #[test]
359 fn render_paragraph_basic() {
360 let blocks = vec![Block::Paragraph { spans: vec![TextSpan::plain("Test paragraph")] }];
361 let theme = ThemeColors::default();
362 let text = render_slide_content(&blocks, &theme);
363 assert!(!text.lines.is_empty());
364 }
365
366 #[test]
367 fn render_code_block() {
368 let blocks = vec![Block::Code(CodeBlock::with_language("rust", "fn main() {}"))];
369 let theme = ThemeColors::default();
370 let text = render_slide_content(&blocks, &theme);
371 assert!(text.lines.len() > 2);
372 }
373
374 #[test]
375 fn render_list_unordered() {
376 let list = List {
377 ordered: false,
378 items: vec![
379 ListItem { spans: vec![TextSpan::plain("Item 1")], nested: None },
380 ListItem { spans: vec![TextSpan::plain("Item 2")], nested: None },
381 ],
382 };
383 let blocks = vec![Block::List(list)];
384 let theme = ThemeColors::default();
385 let text = render_slide_content(&blocks, &theme);
386 assert!(text.lines.len() >= 2);
387 }
388
389 #[test]
390 fn render_styled_text() {
391 let blocks = vec![Block::Paragraph {
392 spans: vec![
393 TextSpan::bold("Bold"),
394 TextSpan::plain(" "),
395 TextSpan::italic("Italic"),
396 TextSpan::plain(" "),
397 TextSpan::code("code"),
398 ],
399 }];
400 let theme = ThemeColors::default();
401 let text = render_slide_content(&blocks, &theme);
402 assert!(!text.lines.is_empty());
403 }
404
405 #[test]
406 fn to_ratatui_style_converts_color() {
407 let color = Color::new(255, 128, 64);
408 let style = to_ratatui_style(&color, false);
409 assert_eq!(style.fg, Some(ratatui::style::Color::Rgb(255, 128, 64)));
410 }
411
412 #[test]
413 fn to_ratatui_style_applies_bold() {
414 let color = Color::new(100, 150, 200);
415 let style = to_ratatui_style(&color, true);
416 assert_eq!(style.fg, Some(ratatui::style::Color::Rgb(100, 150, 200)));
417 assert!(style.add_modifier.contains(Modifier::BOLD));
418 }
419
420 #[test]
421 fn to_ratatui_style_no_bold_when_false() {
422 let color = Color::new(100, 150, 200);
423 let style = to_ratatui_style(&color, false);
424 assert!(!style.add_modifier.contains(Modifier::BOLD));
425 }
426
427 #[test]
428 fn render_heading_uses_theme_colors() {
429 let theme = ThemeColors::default();
430 let blocks = vec![Block::Heading { level: 1, spans: vec![TextSpan::plain("Colored Heading")] }];
431 let text = render_slide_content(&blocks, &theme);
432 assert!(!text.lines.is_empty());
433 assert!(!text.lines.is_empty());
434 }
435
436 #[test]
437 fn apply_theme_style_respects_heading_bold() {
438 let theme = ThemeColors::default();
439 let text_style = TextStyle::default();
440 let style = apply_theme_style(&theme, &text_style, true);
441 assert!(style.add_modifier.contains(Modifier::BOLD));
442 }
443
444 #[test]
445 fn apply_theme_style_uses_code_color_for_code() {
446 let theme = ThemeColors::default();
447 let text_style = TextStyle { code: true, ..Default::default() };
448 let style = apply_theme_style(&theme, &text_style, false);
449
450 assert_eq!(
451 style.fg,
452 Some(ratatui::style::Color::Rgb(theme.code.r, theme.code.g, theme.code.b))
453 );
454 }
455
456 #[test]
457 fn render_slide_with_images_extracts_image() {
458 let blocks =
459 vec![lantern_core::slide::Block::Image { path: "test.png".to_string(), alt: "Test Image".to_string() }];
460 let theme = ThemeColors::default();
461 let (_text, images) = render_slide_with_images(&blocks, &theme);
462
463 assert_eq!(images.len(), 1);
464 assert_eq!(images[0].path, "test.png");
465 assert_eq!(images[0].alt, "Test Image");
466 }
467
468 #[test]
469 fn render_slide_with_images_extracts_multiple() {
470 let blocks = vec![
471 lantern_core::slide::Block::Image { path: "image1.png".to_string(), alt: "First".to_string() },
472 lantern_core::slide::Block::Image { path: "image2.png".to_string(), alt: "Second".to_string() },
473 ];
474 let theme = ThemeColors::default();
475 let (_text, images) = render_slide_with_images(&blocks, &theme);
476
477 assert_eq!(images.len(), 2);
478 assert_eq!(images[0].path, "image1.png");
479 assert_eq!(images[0].alt, "First");
480 assert_eq!(images[1].path, "image2.png");
481 assert_eq!(images[1].alt, "Second");
482 }
483
484 #[test]
485 fn render_slide_with_mixed_content() {
486 let blocks = vec![
487 lantern_core::slide::Block::Heading { level: 1, spans: vec![TextSpan::plain("Title")] },
488 lantern_core::slide::Block::Image { path: "diagram.png".to_string(), alt: "Diagram".to_string() },
489 lantern_core::slide::Block::Paragraph { spans: vec![TextSpan::plain("Description")] },
490 ];
491 let theme = ThemeColors::default();
492 let (text, images) = render_slide_with_images(&blocks, &theme);
493
494 assert!(!text.lines.is_empty());
495 assert_eq!(images.len(), 1);
496 assert_eq!(images[0].path, "diagram.png");
497 }
498}