magical markdown slides
at main 28 kB view raw
1use crate::highlighter; 2use crate::slide::{Block, CodeBlock, List, Table, TextSpan, TextStyle}; 3use crate::theme::ThemeColors; 4use owo_colors::OwoColorize; 5use unicode_width::UnicodeWidthChar; 6 7/// Print slides to stdout with formatted output 8/// 9/// Renders slides as plain text with ANSI colors and width constraints. 10pub fn print_slides_to_stdout( 11 slides: &[crate::slide::Slide], theme: &ThemeColors, width: usize, 12) -> std::io::Result<()> { 13 let stdout = std::io::stdout(); 14 let mut handle = stdout.lock(); 15 print_slides(&mut handle, slides, theme, width) 16} 17 18/// Print slides to any writer with formatted output 19pub fn print_slides<W: std::io::Write>( 20 writer: &mut W, slides: &[crate::slide::Slide], theme: &ThemeColors, width: usize, 21) -> std::io::Result<()> { 22 for (idx, slide) in slides.iter().enumerate() { 23 if idx > 0 { 24 writeln!(writer)?; 25 let sep_text = "".repeat(width); 26 let separator = theme.rule(&sep_text); 27 writeln!(writer, "{separator}")?; 28 writeln!(writer)?; 29 } 30 31 print_slide(writer, slide, theme, width)?; 32 } 33 34 Ok(()) 35} 36 37/// Print a single slide with formatted blocks 38fn print_slide<W: std::io::Write>( 39 writer: &mut W, slide: &crate::slide::Slide, theme: &ThemeColors, width: usize, 40) -> std::io::Result<()> { 41 for block in &slide.blocks { 42 print_block(writer, block, theme, width, 0)?; 43 writeln!(writer)?; 44 } 45 46 Ok(()) 47} 48 49/// Print a single block with appropriate formatting 50fn print_block<W: std::io::Write>( 51 writer: &mut W, block: &Block, theme: &ThemeColors, width: usize, indent: usize, 52) -> std::io::Result<()> { 53 match block { 54 Block::Heading { level, spans } => { 55 print_heading(writer, *level, spans, theme)?; 56 } 57 Block::Paragraph { spans } => { 58 print_paragraph(writer, spans, theme, width, indent)?; 59 } 60 Block::Code(code) => { 61 print_code_block(writer, code, theme, width)?; 62 } 63 Block::List(list) => { 64 print_list(writer, list, theme, width, indent)?; 65 } 66 Block::Rule => { 67 let rule_text = "".repeat(width.saturating_sub(indent)); 68 let rule = theme.rule(&rule_text); 69 writeln!(writer, "{}{}", " ".repeat(indent), rule)?; 70 } 71 Block::BlockQuote { blocks } => { 72 print_blockquote(writer, blocks, theme, width, indent)?; 73 } 74 Block::Table(table) => { 75 print_table(writer, table, theme, width)?; 76 } 77 Block::Admonition(admonition) => { 78 print_admonition(writer, admonition, theme, width, indent)?; 79 } 80 Block::Image { path, alt } => { 81 print_image(writer, path, alt, theme, indent)?; 82 } 83 } 84 85 Ok(()) 86} 87 88/// Print a heading with level-appropriate styling using Unicode block symbols 89fn print_heading<W: std::io::Write>( 90 writer: &mut W, level: u8, spans: &[TextSpan], theme: &ThemeColors, 91) -> std::io::Result<()> { 92 let prefix = match level { 93 1 => "", 94 2 => "", 95 3 => "", 96 4 => "", 97 _ => "", 98 }; 99 100 write!(writer, "{}", theme.heading(&prefix))?; 101 102 for span in spans { 103 print_span(writer, span, theme, true)?; 104 } 105 106 writeln!(writer)?; 107 Ok(()) 108} 109 110/// Print a paragraph with word wrapping 111fn print_paragraph<W: std::io::Write>( 112 writer: &mut W, spans: &[TextSpan], theme: &ThemeColors, width: usize, indent: usize, 113) -> std::io::Result<()> { 114 let indent_str = " ".repeat(indent); 115 let effective_width = width.saturating_sub(indent); 116 117 let text = spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join(""); 118 119 let words: Vec<&str> = text.split_whitespace().collect(); 120 let mut current_line = String::new(); 121 122 for word in words { 123 if current_line.is_empty() { 124 current_line = word.to_string(); 125 } else if current_line.len() + 1 + word.len() <= effective_width { 126 current_line.push(' '); 127 current_line.push_str(word); 128 } else { 129 write!(writer, "{indent_str}")?; 130 for span in spans { 131 if current_line.contains(&span.text) { 132 print_span(writer, span, theme, false)?; 133 break; 134 } 135 } 136 if !spans.is_empty() && !current_line.is_empty() { 137 write!(writer, "{}", theme.body(&current_line))?; 138 } 139 writeln!(writer)?; 140 current_line = word.to_string(); 141 } 142 } 143 144 if !current_line.is_empty() { 145 write!(writer, "{indent_str}")?; 146 for span in spans { 147 print_span(writer, span, theme, false)?; 148 } 149 writeln!(writer)?; 150 } 151 152 Ok(()) 153} 154 155/// Print a code block with syntax highlighting 156fn print_code_block<W: std::io::Write>( 157 writer: &mut W, code: &CodeBlock, theme: &ThemeColors, width: usize, 158) -> std::io::Result<()> { 159 if let Some(lang) = &code.language { 160 writeln!(writer, "{}", theme.code_fence(&format!("```{lang}")))?; 161 } else { 162 writeln!(writer, "{}", theme.code_fence(&"```"))?; 163 } 164 165 let highlighted_lines = highlighter::highlight_code(&code.code, code.language.as_deref(), theme); 166 167 for tokens in highlighted_lines { 168 let mut line_length = 0; 169 for token in tokens { 170 if line_length + token.text.len() > width - 4 { 171 let remaining = (width - 4).saturating_sub(line_length); 172 if remaining > 0 { 173 let trimmed = &token.text[..remaining.min(token.text.len())]; 174 write!(writer, "{}", token.color.to_owo_color(&trimmed))?; 175 } 176 break; 177 } 178 write!(writer, "{}", token.color.to_owo_color(&token.text))?; 179 line_length += token.text.len(); 180 } 181 writeln!(writer)?; 182 } 183 184 writeln!(writer, "{}", theme.code_fence(&"```"))?; 185 Ok(()) 186} 187 188/// Print a list with bullets or numbers 189fn print_list<W: std::io::Write>( 190 writer: &mut W, list: &List, theme: &ThemeColors, _width: usize, indent: usize, 191) -> std::io::Result<()> { 192 for (idx, item) in list.items.iter().enumerate() { 193 let marker = if list.ordered { format!("{}. ", idx + 1) } else { "".to_string() }; 194 195 write!(writer, "{}", " ".repeat(indent))?; 196 write!(writer, "{}", theme.list_marker(&marker))?; 197 198 for span in &item.spans { 199 print_span(writer, span, theme, false)?; 200 } 201 202 writeln!(writer)?; 203 204 if let Some(nested) = &item.nested { 205 print_list(writer, nested, theme, _width, indent + 2)?; 206 } 207 } 208 209 Ok(()) 210} 211 212/// Print a blockquote with border 213fn print_blockquote<W: std::io::Write>( 214 writer: &mut W, blocks: &[Block], theme: &ThemeColors, width: usize, indent: usize, 215) -> std::io::Result<()> { 216 for block in blocks { 217 match block { 218 Block::Paragraph { spans } => { 219 write!(writer, "{}", " ".repeat(indent))?; 220 write!(writer, "{}", theme.blockquote_border(&""))?; 221 for span in spans { 222 print_span(writer, span, theme, false)?; 223 } 224 writeln!(writer)?; 225 } 226 _ => { 227 write!(writer, "{}", " ".repeat(indent))?; 228 write!(writer, "{}", theme.blockquote_border(&""))?; 229 print_block(writer, block, theme, width, indent + 2)?; 230 } 231 } 232 } 233 234 Ok(()) 235} 236 237/// Print an admonition with icon, colored border, and title 238fn print_admonition<W: std::io::Write>( 239 writer: &mut W, admonition: &crate::slide::Admonition, theme: &ThemeColors, width: usize, indent: usize, 240) -> std::io::Result<()> { 241 use crate::slide::AdmonitionType; 242 243 let (icon, color, default_title) = match admonition.admonition_type { 244 AdmonitionType::Note => ("\u{24D8}", &theme.admonition_note, "Note"), 245 AdmonitionType::Tip => ("\u{1F4A1}", &theme.admonition_tip, "Tip"), 246 AdmonitionType::Important => ("\u{2757}", &theme.admonition_tip, "Important"), 247 AdmonitionType::Warning => ("\u{26A0}", &theme.admonition_warning, "Warning"), 248 AdmonitionType::Caution => ("\u{26A0}", &theme.admonition_warning, "Caution"), 249 AdmonitionType::Danger => ("\u{26D4}", &theme.admonition_danger, "Danger"), 250 AdmonitionType::Error => ("\u{2717}", &theme.admonition_danger, "Error"), 251 AdmonitionType::Info => ("\u{24D8}", &theme.admonition_info, "Info"), 252 AdmonitionType::Success => ("\u{2713}", &theme.admonition_success, "Success"), 253 AdmonitionType::Question => ("?", &theme.admonition_info, "Question"), 254 AdmonitionType::Example => ("\u{25B8}", &theme.admonition_success, "Example"), 255 AdmonitionType::Quote => ("\u{201C}", &theme.admonition_info, "Quote"), 256 AdmonitionType::Abstract => ("\u{00A7}", &theme.admonition_note, "Abstract"), 257 AdmonitionType::Todo => ("\u{2610}", &theme.admonition_info, "Todo"), 258 AdmonitionType::Bug => ("\u{1F41B}", &theme.admonition_danger, "Bug"), 259 AdmonitionType::Failure => ("\u{2717}", &theme.admonition_danger, "Failure"), 260 }; 261 262 let title = admonition.title.as_deref().unwrap_or(default_title); 263 let indent_str = " ".repeat(indent); 264 let box_width = width.saturating_sub(indent); 265 266 let top_border = "\u{256D}".to_string() + &"\u{2500}".repeat(box_width.saturating_sub(2)) + "\u{256E}"; 267 writeln!(writer, "{}{}", indent_str, color.to_owo_color(&top_border))?; 268 269 let icon_display_width = icon.chars().next().and_then(|c| c.width()).unwrap_or(1); 270 271 write!(writer, "{}{} ", indent_str, color.to_owo_color(&"\u{2502}"))?; 272 write!(writer, "{icon} ")?; 273 write!(writer, "{}", color.to_owo_color(&title).bold())?; 274 275 let title_padding = box_width.saturating_sub(4 + icon_display_width + 1 + title.len()); 276 write!(writer, "{}", " ".repeat(title_padding))?; 277 writeln!(writer, " {}", color.to_owo_color(&"\u{2502}"))?; 278 279 if !admonition.blocks.is_empty() { 280 let separator = "\u{251C}".to_string() + &"\u{2500}".repeat(box_width.saturating_sub(2)) + "\u{2524}"; 281 writeln!(writer, "{}{}", indent_str, color.to_owo_color(&separator))?; 282 283 for block in &admonition.blocks { 284 match block { 285 Block::Paragraph { spans } => { 286 print_wrapped_admonition_paragraph(writer, spans, theme, color, &indent_str, box_width)?; 287 } 288 _ => { 289 write!(writer, "{}{} ", indent_str, color.to_owo_color(&"\u{2502}"))?; 290 print_block(writer, block, theme, box_width.saturating_sub(4), indent + 2)?; 291 writeln!(writer, "{}", color.to_owo_color(&"\u{2502}"))?; 292 } 293 } 294 } 295 } 296 297 let bottom_border = "\u{2570}".to_string() + &"\u{2500}".repeat(box_width.saturating_sub(2)) + "\u{256F}"; 298 writeln!(writer, "{}{}", indent_str, color.to_owo_color(&bottom_border))?; 299 300 Ok(()) 301} 302 303/// Print an image placeholder with path and alt text 304fn print_image<W: std::io::Write>( 305 writer: &mut W, path: &str, alt: &str, theme: &ThemeColors, indent: usize, 306) -> std::io::Result<()> { 307 let indent_str = " ".repeat(indent); 308 let icon = "\u{1F5BC}"; 309 310 write!(writer, "{indent_str}{}", theme.heading(&format!("{icon} Image: ")))?; 311 312 if !alt.is_empty() { 313 writeln!(writer, "{}", theme.heading(&alt))?; 314 } else { 315 writeln!(writer)?; 316 } 317 318 writeln!(writer, "{} Path: {}", indent_str, theme.body(&path))?; 319 320 Ok(()) 321} 322 323/// Print a wrapped paragraph inside an admonition with proper text wrapping 324fn print_wrapped_admonition_paragraph<W: std::io::Write>( 325 writer: &mut W, spans: &[TextSpan], theme: &ThemeColors, border_color: &crate::theme::Color, indent_str: &str, 326 box_width: usize, 327) -> std::io::Result<()> { 328 let text = spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join(""); 329 let words: Vec<&str> = text.split_whitespace().collect(); 330 331 let content_width = box_width.saturating_sub(4); 332 let mut current_line = String::new(); 333 334 for word in words { 335 if current_line.is_empty() { 336 current_line = word.to_string(); 337 } else if current_line.len() + 1 + word.len() <= content_width { 338 current_line.push(' '); 339 current_line.push_str(word); 340 } else { 341 write!(writer, "{}{} ", indent_str, border_color.to_owo_color(&"\u{2502}"))?; 342 write!(writer, "{}", theme.body(&current_line))?; 343 let padding = content_width.saturating_sub(current_line.len()); 344 write!(writer, "{}", " ".repeat(padding))?; 345 writeln!(writer, "{}", border_color.to_owo_color(&"\u{2502}"))?; 346 current_line = word.to_string(); 347 } 348 } 349 350 if !current_line.is_empty() { 351 write!(writer, "{}{} ", indent_str, border_color.to_owo_color(&"\u{2502}"))?; 352 write!(writer, "{}", theme.body(&current_line))?; 353 let padding = content_width.saturating_sub(current_line.len()); 354 write!(writer, "{}", " ".repeat(padding))?; 355 writeln!(writer, "{}", border_color.to_owo_color(&"\u{2502}"))?; 356 } 357 358 Ok(()) 359} 360 361/// Print a table with borders and proper column width calculation 362/// 363/// Calculates column widths based on content and distributes available space 364fn print_table<W: std::io::Write>( 365 writer: &mut W, table: &Table, theme: &ThemeColors, width: usize, 366) -> std::io::Result<()> { 367 let col_count = table.headers.len(); 368 if col_count == 0 { 369 return Ok(()); 370 } 371 372 let col_widths = calculate_column_widths(table, width); 373 374 if !table.headers.is_empty() { 375 print_table_row(writer, &table.headers, &col_widths, theme, true)?; 376 377 let separator = build_table_separator(&col_widths); 378 writeln!(writer, "{}", theme.table_border(&separator))?; 379 } 380 381 for row in &table.rows { 382 print_table_row(writer, row, &col_widths, theme, false)?; 383 } 384 385 Ok(()) 386} 387 388/// Calculate column widths based on content and available space 389fn calculate_column_widths(table: &Table, max_width: usize) -> Vec<usize> { 390 let col_count = table.headers.len(); 391 if col_count == 0 { 392 return vec![]; 393 } 394 395 let mut col_widths = vec![0; col_count]; 396 397 for (col_idx, header) in table.headers.iter().enumerate() { 398 let content_len: usize = header.iter().map(|s| s.text.len()).sum(); 399 col_widths[col_idx] = content_len.max(3); 400 } 401 402 for row in &table.rows { 403 for (col_idx, cell) in row.iter().enumerate() { 404 if col_idx < col_widths.len() { 405 let content_len = cell.iter().map(|s| s.text.len()).sum(); 406 col_widths[col_idx] = col_widths[col_idx].max(content_len); 407 } 408 } 409 } 410 411 let separator_width = (col_count - 1) * 3; 412 let padding_width = col_count * 2; 413 let available_width = max_width.saturating_sub(separator_width + padding_width); 414 415 let total_content_width: usize = col_widths.iter().sum(); 416 417 if total_content_width > available_width { 418 let scale_factor = available_width as f64 / total_content_width as f64; 419 for width in &mut col_widths { 420 *width = ((*width as f64 * scale_factor).ceil() as usize).max(3); 421 } 422 } 423 424 col_widths 425} 426 427/// Build a table separator line with proper column separators 428fn build_table_separator(col_widths: &[usize]) -> String { 429 let mut separator = String::new(); 430 for (idx, &width) in col_widths.iter().enumerate() { 431 if idx > 0 { 432 separator.push_str("─┼─"); 433 } 434 separator.push_str(&"".repeat(width + 2)); 435 } 436 separator 437} 438 439/// Print a single table row with proper padding and alignment 440fn print_table_row<W: std::io::Write>( 441 writer: &mut W, cells: &[Vec<TextSpan>], col_widths: &[usize], theme: &ThemeColors, is_header: bool, 442) -> std::io::Result<()> { 443 for (idx, cell) in cells.iter().enumerate() { 444 if idx > 0 { 445 write!(writer, "{}", theme.table_border(&""))?; 446 } else { 447 write!(writer, " ")?; 448 } 449 450 let col_width = col_widths.get(idx).copied().unwrap_or(10); 451 let content: String = cell.iter().map(|s| s.text.as_str()).collect(); 452 let content_len = content.len(); 453 454 for span in cell { 455 print_span(writer, span, theme, is_header)?; 456 } 457 458 if content_len < col_width { 459 write!(writer, "{}", " ".repeat(col_width - content_len))?; 460 } 461 462 write!(writer, " ")?; 463 } 464 writeln!(writer)?; 465 466 Ok(()) 467} 468 469/// Print a text span with styling 470fn print_span<W: std::io::Write>( 471 writer: &mut W, span: &TextSpan, theme: &ThemeColors, is_heading: bool, 472) -> std::io::Result<()> { 473 let text = &span.text; 474 let style = &span.style; 475 476 if is_heading { 477 write!(writer, "{}", apply_text_style(&theme.heading(text), style))?; 478 } else if style.code { 479 write!(writer, "{}", apply_text_style(&theme.code(text), style))?; 480 } else { 481 write!(writer, "{}", apply_text_style(&theme.body(text), style))?; 482 } 483 484 Ok(()) 485} 486 487/// Apply text style modifiers to styled text 488fn apply_text_style<T: std::fmt::Display>(styled: &owo_colors::Styled<T>, text_style: &TextStyle) -> String { 489 let mut result = styled.to_string(); 490 491 if text_style.bold { 492 result = format!("\x1b[1m{result}\x1b[22m"); 493 } 494 if text_style.italic { 495 result = format!("\x1b[3m{result}\x1b[23m"); 496 } 497 if text_style.strikethrough { 498 result = format!("\x1b[9m{result}\x1b[29m"); 499 } 500 501 result 502} 503 504#[cfg(test)] 505mod tests { 506 use super::*; 507 use crate::slide::Slide; 508 use crate::slide::{Alignment, Table}; 509 510 #[test] 511 fn print_empty_slides() { 512 let slides: Vec<Slide> = vec![]; 513 let theme = ThemeColors::default(); 514 let mut output = Vec::new(); 515 516 let result = print_slides(&mut output, &slides, &theme, 80); 517 assert!(result.is_ok()); 518 assert_eq!(output.len(), 0); 519 } 520 521 #[test] 522 fn print_single_heading() { 523 let slide = Slide::with_blocks(vec![Block::Heading { 524 level: 1, 525 spans: vec![TextSpan::plain("Hello World")], 526 }]); 527 let theme = ThemeColors::default(); 528 let mut output = Vec::new(); 529 530 let result = print_slides(&mut output, &[slide], &theme, 80); 531 assert!(result.is_ok()); 532 let text = String::from_utf8_lossy(&output); 533 assert!(text.contains("Hello World")); 534 } 535 536 #[test] 537 fn print_paragraph_with_wrapping() { 538 let long_text = "This is a very long paragraph that should wrap when printed to stdout with a width constraint applied to ensure readability."; 539 let slide = Slide::with_blocks(vec![Block::Paragraph { spans: vec![TextSpan::plain(long_text)] }]); 540 let theme = ThemeColors::default(); 541 let mut output = Vec::new(); 542 543 let result = print_slides(&mut output, &[slide], &theme, 40); 544 assert!(result.is_ok()); 545 } 546 547 #[test] 548 fn print_code_block() { 549 let slide = Slide::with_blocks(vec![Block::Code(CodeBlock::with_language( 550 "rust", 551 "fn main() {\n println!(\"Hello\");\n}", 552 ))]); 553 let theme = ThemeColors::default(); 554 let mut output = Vec::new(); 555 556 let result = print_slides(&mut output, &[slide], &theme, 80); 557 assert!(result.is_ok()); 558 559 let text = String::from_utf8_lossy(&output); 560 assert!(text.contains("```rust")); 561 assert!(text.contains("fn") && text.contains("main")); 562 assert!(text.contains("println")); 563 } 564 565 #[test] 566 fn print_multiple_slides() { 567 let slides = vec![ 568 Slide::with_blocks(vec![Block::Heading { 569 level: 1, 570 spans: vec![TextSpan::plain("Slide 1")], 571 }]), 572 Slide::with_blocks(vec![Block::Heading { 573 level: 1, 574 spans: vec![TextSpan::plain("Slide 2")], 575 }]), 576 ]; 577 578 let theme = ThemeColors::default(); 579 let mut output = Vec::new(); 580 let result = print_slides(&mut output, &slides, &theme, 80); 581 assert!(result.is_ok()); 582 583 let text = String::from_utf8_lossy(&output); 584 assert!(text.contains("Slide 1")); 585 assert!(text.contains("Slide 2")); 586 } 587 588 #[test] 589 fn print_table_with_headers() { 590 let table = Table { 591 headers: vec![ 592 vec![TextSpan::plain("Name")], 593 vec![TextSpan::plain("Age")], 594 vec![TextSpan::plain("City")], 595 ], 596 rows: vec![ 597 vec![ 598 vec![TextSpan::plain("Alice")], 599 vec![TextSpan::plain("30")], 600 vec![TextSpan::plain("NYC")], 601 ], 602 vec![ 603 vec![TextSpan::plain("Bob")], 604 vec![TextSpan::plain("25")], 605 vec![TextSpan::plain("LA")], 606 ], 607 ], 608 alignments: vec![Alignment::Left, Alignment::Left, Alignment::Left], 609 }; 610 611 let slide = Slide::with_blocks(vec![Block::Table(table)]); 612 let theme = ThemeColors::default(); 613 let mut output = Vec::new(); 614 615 let result = print_slides(&mut output, &[slide], &theme, 80); 616 assert!(result.is_ok()); 617 618 let text = String::from_utf8_lossy(&output); 619 assert!(text.contains("Name")); 620 assert!(text.contains("Age")); 621 assert!(text.contains("City")); 622 assert!(text.contains("Alice")); 623 assert!(text.contains("Bob")); 624 assert!(text.contains("")); 625 assert!(text.contains("")); 626 } 627 628 #[test] 629 fn print_table_with_column_width_calculation() { 630 let table = Table { 631 headers: vec![vec![TextSpan::plain("Short")], vec![TextSpan::plain("Long Header")]], 632 rows: vec![ 633 vec![vec![TextSpan::plain("A")], vec![TextSpan::plain("B")]], 634 vec![vec![TextSpan::plain("Very Long Content")], vec![TextSpan::plain("X")]], 635 ], 636 alignments: vec![Alignment::Left, Alignment::Left], 637 }; 638 639 let col_widths = calculate_column_widths(&table, 80); 640 641 assert_eq!(col_widths.len(), 2); 642 assert!(col_widths[0] >= 17); 643 assert!(col_widths[1] >= 11); 644 } 645 646 #[test] 647 fn print_table_empty_headers() { 648 let table = Table { headers: vec![], rows: vec![], alignments: vec![] }; 649 650 let slide = Slide::with_blocks(vec![Block::Table(table)]); 651 let theme = ThemeColors::default(); 652 let mut output = Vec::new(); 653 654 let result = print_slides(&mut output, &[slide], &theme, 80); 655 assert!(result.is_ok()); 656 } 657 658 #[test] 659 fn calculate_column_widths_scales_to_fit() { 660 let table = Table { 661 headers: vec![ 662 vec![TextSpan::plain("A".repeat(50))], 663 vec![TextSpan::plain("B".repeat(50))], 664 ], 665 rows: vec![], 666 alignments: vec![Alignment::Left, Alignment::Left], 667 }; 668 669 let col_widths = calculate_column_widths(&table, 40); 670 let total_width: usize = col_widths.iter().sum(); 671 672 assert!(total_width <= 40); 673 } 674 675 #[test] 676 fn build_table_separator_correct_format() { 677 let col_widths = vec![5, 10, 7]; 678 let separator = build_table_separator(&col_widths); 679 680 assert!(separator.contains("─┼─")); 681 assert!(separator.contains("")); 682 } 683 684 #[test] 685 fn print_admonition_with_wrapping() { 686 use crate::slide::{Admonition, AdmonitionType}; 687 688 let admonition = Admonition { 689 admonition_type: AdmonitionType::Tip, 690 title: Some("Tip".to_string()), 691 blocks: vec![Block::Paragraph { 692 spans: vec![TextSpan::plain( 693 "Variables are immutable by default - use mut only when you need to change values", 694 )], 695 }], 696 }; 697 698 let slide = Slide::with_blocks(vec![Block::Admonition(admonition)]); 699 let theme = ThemeColors::default(); 700 let mut output = Vec::new(); 701 702 let result = print_slides(&mut output, &[slide], &theme, 80); 703 assert!(result.is_ok()); 704 705 let text = String::from_utf8_lossy(&output); 706 assert!(text.contains("Tip")); 707 assert!(text.contains("Variables are immutable")); 708 assert!(text.contains("mut")); 709 assert!(text.contains("") && text.contains("")); 710 assert!(text.contains("") && text.contains("")); 711 assert!(text.contains("") && text.contains("")); 712 assert!(text.contains("")); 713 } 714 715 #[test] 716 fn print_admonition_border_length() { 717 use crate::slide::{Admonition, AdmonitionType}; 718 719 let admonition = Admonition { 720 admonition_type: AdmonitionType::Note, 721 title: None, 722 blocks: vec![Block::Paragraph { spans: vec![TextSpan::plain("Test content")] }], 723 }; 724 725 let slide = Slide::with_blocks(vec![Block::Admonition(admonition)]); 726 let theme = ThemeColors::default(); 727 let mut output = Vec::new(); 728 729 let width = 60; 730 let result = print_slides(&mut output, &[slide], &theme, width); 731 assert!(result.is_ok()); 732 733 let text = String::from_utf8_lossy(&output); 734 let lines: Vec<&str> = text.lines().collect(); 735 736 for line in &lines { 737 if line.contains("") || line.contains("") || line.contains("") { 738 let stripped = strip_ansi_codes(line); 739 let visible_len = stripped.chars().count(); 740 assert!( 741 visible_len <= width, 742 "Border line too long: {visible_len} chars (max {width})\nLine: {stripped}" 743 ); 744 } 745 } 746 } 747 748 fn strip_ansi_codes(s: &str) -> String { 749 let mut result = String::new(); 750 let mut chars = s.chars().peekable(); 751 752 while let Some(c) = chars.next() { 753 if c == '\x1b' { 754 if chars.peek() == Some(&'[') { 755 chars.next(); 756 for ch in chars.by_ref() { 757 if ch.is_ascii_alphabetic() { 758 break; 759 } 760 } 761 } 762 } else { 763 result.push(c); 764 } 765 } 766 767 result 768 } 769 770 #[test] 771 fn print_admonition_wraps_long_text() { 772 use crate::slide::{Admonition, AdmonitionType}; 773 774 let long_text = "This is a very long text that should definitely wrap across multiple lines when rendered in a narrow width to ensure readability and proper formatting"; 775 776 let admonition = Admonition { 777 admonition_type: AdmonitionType::Warning, 778 title: Some("Warning".to_string()), 779 blocks: vec![Block::Paragraph { spans: vec![TextSpan::plain(long_text)] }], 780 }; 781 782 let slide = Slide::with_blocks(vec![Block::Admonition(admonition)]); 783 let theme = ThemeColors::default(); 784 let mut output = Vec::new(); 785 786 let result = print_slides(&mut output, &[slide], &theme, 50); 787 assert!(result.is_ok()); 788 789 let text = String::from_utf8_lossy(&output); 790 let content_lines: Vec<&str> = text 791 .lines() 792 .filter(|line| line.contains("") && !line.contains("") && !line.contains("") && !line.contains("")) 793 .collect(); 794 795 assert!(content_lines.len() > 2, "Long text should wrap to multiple lines"); 796 } 797}