magical markdown slides
at 769f04da397e1abfaaa62271b484e1d3cdcfa8e5 265 lines 8.7 kB view raw
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}