magical markdown slides
at 769f04da397e1abfaaa62271b484e1d3cdcfa8e5 369 lines 12 kB view raw
1use crate::slide::{Block, CodeBlock, List, Table, TextSpan, TextStyle}; 2use crate::theme::ThemeColors; 3 4/// Print slides to stdout with formatted output 5/// 6/// Renders slides as plain text with ANSI colors and width constraints. 7pub fn print_slides_to_stdout( 8 slides: &[crate::slide::Slide], theme: &ThemeColors, width: usize, 9) -> std::io::Result<()> { 10 let stdout = std::io::stdout(); 11 let mut handle = stdout.lock(); 12 print_slides(&mut handle, slides, theme, width) 13} 14 15/// Print slides to any writer with formatted output 16pub fn print_slides<W: std::io::Write>( 17 writer: &mut W, slides: &[crate::slide::Slide], theme: &ThemeColors, width: usize, 18) -> std::io::Result<()> { 19 for (idx, slide) in slides.iter().enumerate() { 20 if idx > 0 { 21 writeln!(writer)?; 22 let sep_text = "".repeat(width); 23 let separator = theme.rule(&sep_text); 24 writeln!(writer, "{}", separator)?; 25 writeln!(writer)?; 26 } 27 28 print_slide(writer, slide, theme, width)?; 29 } 30 31 Ok(()) 32} 33 34/// Print a single slide with formatted blocks 35fn print_slide<W: std::io::Write>( 36 writer: &mut W, slide: &crate::slide::Slide, theme: &ThemeColors, width: usize, 37) -> std::io::Result<()> { 38 for block in &slide.blocks { 39 print_block(writer, block, theme, width, 0)?; 40 writeln!(writer)?; 41 } 42 43 Ok(()) 44} 45 46/// Print a single block with appropriate formatting 47fn print_block<W: std::io::Write>( 48 writer: &mut W, block: &Block, theme: &ThemeColors, width: usize, indent: usize, 49) -> std::io::Result<()> { 50 match block { 51 Block::Heading { level, spans } => { 52 print_heading(writer, *level, spans, theme)?; 53 } 54 Block::Paragraph { spans } => { 55 print_paragraph(writer, spans, theme, width, indent)?; 56 } 57 Block::Code(code) => { 58 print_code_block(writer, code, theme, width)?; 59 } 60 Block::List(list) => { 61 print_list(writer, list, theme, width, indent)?; 62 } 63 Block::Rule => { 64 let rule_text = "".repeat(width.saturating_sub(indent)); 65 let rule = theme.rule(&rule_text); 66 writeln!(writer, "{}{}", " ".repeat(indent), rule)?; 67 } 68 Block::BlockQuote { blocks } => { 69 print_blockquote(writer, blocks, theme, width, indent)?; 70 } 71 Block::Table(table) => { 72 print_table(writer, table, theme, width)?; 73 } 74 } 75 76 Ok(()) 77} 78 79/// Print a heading with level-appropriate styling 80fn print_heading<W: std::io::Write>( 81 writer: &mut W, level: u8, spans: &[TextSpan], theme: &ThemeColors, 82) -> std::io::Result<()> { 83 let prefix = match level { 84 1 => "# ", 85 2 => "## ", 86 3 => "### ", 87 4 => "#### ", 88 5 => "##### ", 89 _ => "###### ", 90 }; 91 92 write!(writer, "{}", theme.heading(&prefix))?; 93 94 for span in spans { 95 print_span(writer, span, theme, true)?; 96 } 97 98 writeln!(writer)?; 99 Ok(()) 100} 101 102/// Print a paragraph with word wrapping 103fn print_paragraph<W: std::io::Write>( 104 writer: &mut W, spans: &[TextSpan], theme: &ThemeColors, width: usize, indent: usize, 105) -> std::io::Result<()> { 106 let indent_str = " ".repeat(indent); 107 let effective_width = width.saturating_sub(indent); 108 109 let text = spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join(""); 110 111 let words: Vec<&str> = text.split_whitespace().collect(); 112 let mut current_line = String::new(); 113 114 for word in words { 115 if current_line.is_empty() { 116 current_line = word.to_string(); 117 } else if current_line.len() + 1 + word.len() <= effective_width { 118 current_line.push(' '); 119 current_line.push_str(word); 120 } else { 121 write!(writer, "{}", indent_str)?; 122 for span in spans { 123 if current_line.contains(&span.text) { 124 print_span(writer, span, theme, false)?; 125 break; 126 } 127 } 128 if !spans.is_empty() && !current_line.is_empty() { 129 write!(writer, "{}", theme.body(&current_line))?; 130 } 131 writeln!(writer)?; 132 current_line = word.to_string(); 133 } 134 } 135 136 if !current_line.is_empty() { 137 write!(writer, "{}", indent_str)?; 138 for span in spans { 139 print_span(writer, span, theme, false)?; 140 } 141 writeln!(writer)?; 142 } 143 144 Ok(()) 145} 146 147/// Print a code block with language tag 148fn print_code_block<W: std::io::Write>( 149 writer: &mut W, code: &CodeBlock, theme: &ThemeColors, width: usize, 150) -> std::io::Result<()> { 151 if let Some(lang) = &code.language { 152 writeln!(writer, "{}", theme.code_fence(&format!("```{}", lang)))?; 153 } else { 154 writeln!(writer, "{}", theme.code_fence(&"```"))?; 155 } 156 157 for line in code.code.lines() { 158 let trimmed = if line.len() > width - 4 { &line[..width - 4] } else { line }; 159 writeln!(writer, "{}", theme.code(&trimmed))?; 160 } 161 162 writeln!(writer, "{}", theme.code_fence(&"```"))?; 163 Ok(()) 164} 165 166/// Print a list with bullets or numbers 167fn print_list<W: std::io::Write>( 168 writer: &mut W, list: &List, theme: &ThemeColors, width: usize, indent: usize, 169) -> std::io::Result<()> { 170 for (idx, item) in list.items.iter().enumerate() { 171 let marker = if list.ordered { format!("{}. ", idx + 1) } else { "".to_string() }; 172 173 write!(writer, "{}", " ".repeat(indent))?; 174 write!(writer, "{}", theme.list_marker(&marker))?; 175 176 for span in &item.spans { 177 print_span(writer, span, theme, false)?; 178 } 179 180 writeln!(writer)?; 181 182 if let Some(nested) = &item.nested { 183 print_list(writer, nested, theme, width, indent + 2)?; 184 } 185 } 186 187 Ok(()) 188} 189 190/// Print a blockquote with border 191fn print_blockquote<W: std::io::Write>( 192 writer: &mut W, blocks: &[Block], theme: &ThemeColors, width: usize, indent: usize, 193) -> std::io::Result<()> { 194 for block in blocks { 195 match block { 196 Block::Paragraph { spans } => { 197 write!(writer, "{}", " ".repeat(indent))?; 198 write!(writer, "{}", theme.blockquote_border(&""))?; 199 for span in spans { 200 print_span(writer, span, theme, false)?; 201 } 202 writeln!(writer)?; 203 } 204 _ => { 205 write!(writer, "{}", " ".repeat(indent))?; 206 write!(writer, "{}", theme.blockquote_border(&""))?; 207 print_block(writer, block, theme, width, indent + 2)?; 208 } 209 } 210 } 211 212 Ok(()) 213} 214 215/// Print a table with borders 216/// 217/// TODO: Implement proper column width calculation and alignment 218fn print_table<W: std::io::Write>( 219 writer: &mut W, table: &Table, theme: &ThemeColors, width: usize, 220) -> std::io::Result<()> { 221 let col_count = table.headers.len(); 222 let _col_width = if col_count > 0 { (width.saturating_sub(col_count * 3)) / col_count } else { width }; 223 224 if !table.headers.is_empty() { 225 for (idx, header) in table.headers.iter().enumerate() { 226 if idx > 0 { 227 write!(writer, "{}", theme.table_border(&""))?; 228 } 229 for span in header { 230 print_span(writer, span, theme, true)?; 231 } 232 } 233 writeln!(writer)?; 234 235 let separator = "".repeat(width); 236 writeln!(writer, "{}", theme.table_border(&separator))?; 237 } 238 239 for row in &table.rows { 240 for (idx, cell) in row.iter().enumerate() { 241 if idx > 0 { 242 write!(writer, "{}", theme.table_border(&""))?; 243 } 244 for span in cell { 245 print_span(writer, span, theme, false)?; 246 } 247 } 248 writeln!(writer)?; 249 } 250 251 Ok(()) 252} 253 254/// Print a text span with styling 255fn print_span<W: std::io::Write>( 256 writer: &mut W, span: &TextSpan, theme: &ThemeColors, is_heading: bool, 257) -> std::io::Result<()> { 258 let text = &span.text; 259 let style = &span.style; 260 261 if is_heading { 262 write!(writer, "{}", apply_text_style(&theme.heading(text), style))?; 263 } else if style.code { 264 write!(writer, "{}", apply_text_style(&theme.code(text), style))?; 265 } else { 266 write!(writer, "{}", apply_text_style(&theme.body(text), style))?; 267 } 268 269 Ok(()) 270} 271 272/// Apply text style modifiers to styled text 273fn apply_text_style<T: std::fmt::Display>(styled: &owo_colors::Styled<T>, text_style: &TextStyle) -> String { 274 let mut result = styled.to_string(); 275 276 if text_style.bold { 277 result = format!("\x1b[1m{}\x1b[22m", result); 278 } 279 if text_style.italic { 280 result = format!("\x1b[3m{}\x1b[23m", result); 281 } 282 if text_style.strikethrough { 283 result = format!("\x1b[9m{}\x1b[29m", result); 284 } 285 286 result 287} 288 289#[cfg(test)] 290mod tests { 291 use super::*; 292 use crate::slide::Slide; 293 294 #[test] 295 fn print_empty_slides() { 296 let slides: Vec<Slide> = vec![]; 297 let theme = ThemeColors::default(); 298 let mut output = Vec::new(); 299 300 let result = print_slides(&mut output, &slides, &theme, 80); 301 assert!(result.is_ok()); 302 assert_eq!(output.len(), 0); 303 } 304 305 #[test] 306 fn print_single_heading() { 307 let slide = Slide::with_blocks(vec![Block::Heading { 308 level: 1, 309 spans: vec![TextSpan::plain("Hello World")], 310 }]); 311 let theme = ThemeColors::default(); 312 let mut output = Vec::new(); 313 314 let result = print_slides(&mut output, &[slide], &theme, 80); 315 assert!(result.is_ok()); 316 let text = String::from_utf8_lossy(&output); 317 assert!(text.contains("Hello World")); 318 } 319 320 #[test] 321 fn print_paragraph_with_wrapping() { 322 let long_text = "This is a very long paragraph that should wrap when printed to stdout with a width constraint applied to ensure readability."; 323 let slide = Slide::with_blocks(vec![Block::Paragraph { spans: vec![TextSpan::plain(long_text)] }]); 324 let theme = ThemeColors::default(); 325 let mut output = Vec::new(); 326 327 let result = print_slides(&mut output, &[slide], &theme, 40); 328 assert!(result.is_ok()); 329 } 330 331 #[test] 332 fn print_code_block() { 333 let slide = Slide::with_blocks(vec![Block::Code(CodeBlock::with_language( 334 "rust", 335 "fn main() {\n println!(\"Hello\");\n}", 336 ))]); 337 let theme = ThemeColors::default(); 338 let mut output = Vec::new(); 339 340 let result = print_slides(&mut output, &[slide], &theme, 80); 341 assert!(result.is_ok()); 342 let text = String::from_utf8_lossy(&output); 343 assert!(text.contains("```rust")); 344 assert!(text.contains("fn main()")); 345 } 346 347 #[test] 348 fn print_multiple_slides() { 349 let slides = vec![ 350 Slide::with_blocks(vec![Block::Heading { 351 level: 1, 352 spans: vec![TextSpan::plain("Slide 1")], 353 }]), 354 Slide::with_blocks(vec![Block::Heading { 355 level: 1, 356 spans: vec![TextSpan::plain("Slide 2")], 357 }]), 358 ]; 359 360 let theme = ThemeColors::default(); 361 let mut output = Vec::new(); 362 let result = print_slides(&mut output, &slides, &theme, 80); 363 assert!(result.is_ok()); 364 365 let text = String::from_utf8_lossy(&output); 366 assert!(text.contains("Slide 1")); 367 assert!(text.contains("Slide 2")); 368 } 369}