magical markdown slides
at main 19 kB view raw
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}