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