magical markdown slides
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(¤t_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(¤t_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(¤t_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}