magical markdown slides
at 769f04da397e1abfaaa62271b484e1d3cdcfa8e5 265 lines 8.2 kB view raw
1use clap::{Parser, Subcommand}; 2use ratatui::{Terminal, backend::CrosstermBackend}; 3use slides_core::{parser::parse_slides_with_meta, term::Terminal as SlideTerminal, theme::ThemeColors}; 4use slides_tui::App; 5use std::{io, path::PathBuf}; 6use tracing::Level; 7 8/// A modern terminal-based presentation tool 9#[derive(Parser, Debug)] 10#[command(name = "slides")] 11#[command(version, about, long_about = None)] 12struct ArgParser { 13 /// Set logging level (error, warn, info, debug, trace) 14 #[arg(short, long, global = true, default_value = "info")] 15 log_level: Level, 16 17 #[command(subcommand)] 18 command: Commands, 19} 20 21#[derive(Subcommand, Debug)] 22enum Commands { 23 /// Present slides in interactive TUI mode 24 Present { 25 /// Path to the markdown file 26 file: PathBuf, 27 /// Theme to use for presentation 28 #[arg(short, long)] 29 theme: Option<String>, 30 }, 31 32 /// Print slides to stdout with formatting 33 Print { 34 /// Path to the markdown file 35 file: PathBuf, 36 /// Maximum width for output (in characters) 37 #[arg(short, long, default_value = "80")] 38 width: usize, 39 /// Theme to use for coloring 40 #[arg(short, long)] 41 theme: Option<String>, 42 }, 43 44 /// Initialize a new slide deck with example content 45 Init { 46 /// Directory to create the deck in 47 #[arg(default_value = ".")] 48 path: PathBuf, 49 /// Name of the deck file 50 #[arg(short, long, default_value = "slides.md")] 51 name: String, 52 }, 53 54 /// Check slides for errors and lint issues 55 Check { 56 /// Path to the markdown file 57 file: PathBuf, 58 /// Enable strict mode with additional checks 59 #[arg(short, long)] 60 strict: bool, 61 }, 62} 63 64fn main() { 65 let cli = ArgParser::parse(); 66 67 tracing_subscriber::fmt().with_max_level(cli.log_level).init(); 68 69 match cli.command { 70 Commands::Present { file, theme } => { 71 if let Err(e) = run_present(&file, theme) { 72 eprintln!("Error: {}", e); 73 std::process::exit(1); 74 } 75 } 76 77 Commands::Print { file, width, theme } => { 78 if let Err(e) = run_print(&file, width, theme) { 79 eprintln!("Error: {}", e); 80 std::process::exit(1); 81 } 82 } 83 84 Commands::Init { path, name } => { 85 tracing::info!("Initializing new deck: {} in {}", name, path.display()); 86 eprintln!("Init command not yet implemented"); 87 } 88 89 Commands::Check { file, strict } => { 90 tracing::info!("Checking slides: {}", file.display()); 91 if strict { 92 tracing::debug!("Strict mode enabled"); 93 } 94 eprintln!("Check command not yet implemented"); 95 } 96 } 97} 98 99fn run_present(file: &PathBuf, theme_arg: Option<String>) -> io::Result<()> { 100 tracing::info!("Presenting slides from: {}", file.display()); 101 102 let markdown = std::fs::read_to_string(file) 103 .map_err(|e| io::Error::new(e.kind(), format!("Failed to read file {}: {}", file.display(), e)))?; 104 105 let (meta, slides) = parse_slides_with_meta(&markdown) 106 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("Parse error: {}", e)))?; 107 108 if slides.is_empty() { 109 return Err(io::Error::new(io::ErrorKind::InvalidData, "No slides found in file")); 110 } 111 112 let theme_name = theme_arg.unwrap_or_else(|| meta.theme.clone()); 113 tracing::debug!("Using theme: {}", theme_name); 114 115 let theme = ThemeColors::default(); 116 117 let filename = file 118 .file_name() 119 .and_then(|n| n.to_str()) 120 .unwrap_or("unknown") 121 .to_string(); 122 123 let mut slide_terminal = SlideTerminal::setup()?; 124 125 let result = (|| -> io::Result<()> { 126 let stdout = io::stdout(); 127 let backend = CrosstermBackend::new(stdout); 128 let mut terminal = Terminal::new(backend)?; 129 130 terminal.clear()?; 131 132 let mut app = App::new(slides, theme, filename, meta); 133 app.run(&mut terminal)?; 134 135 Ok(()) 136 })(); 137 138 slide_terminal.restore()?; 139 140 result 141} 142 143fn run_print(file: &PathBuf, width: usize, theme_arg: Option<String>) -> io::Result<()> { 144 tracing::info!("Printing slides from: {} (width: {})", file.display(), width); 145 146 let markdown = std::fs::read_to_string(file) 147 .map_err(|e| io::Error::new(e.kind(), format!("Failed to read file {}: {}", file.display(), e)))?; 148 149 let (meta, slides) = parse_slides_with_meta(&markdown) 150 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("Parse error: {}", e)))?; 151 152 if slides.is_empty() { 153 return Err(io::Error::new(io::ErrorKind::InvalidData, "No slides found in file")); 154 } 155 156 let theme_name = theme_arg.unwrap_or_else(|| meta.theme.clone()); 157 tracing::debug!("Using theme: {}", theme_name); 158 159 // TODO: Load theme from theme registry based on theme_name 160 let theme = ThemeColors::default(); 161 162 slides_core::printer::print_slides_to_stdout(&slides, &theme, width)?; 163 164 Ok(()) 165} 166 167#[cfg(test)] 168mod tests { 169 use super::*; 170 171 #[test] 172 fn cli_present_command() { 173 let cli = ArgParser::parse_from(["slides", "present", "test.md"]); 174 match cli.command { 175 Commands::Present { file, theme } => { 176 assert_eq!(file, PathBuf::from("test.md")); 177 assert_eq!(theme, None); 178 } 179 _ => panic!("Expected Present command"), 180 } 181 } 182 183 #[test] 184 fn cli_present_with_theme() { 185 let cli = ArgParser::parse_from(["slides", "present", "test.md", "--theme", "dark"]); 186 match cli.command { 187 Commands::Present { file, theme } => { 188 assert_eq!(file, PathBuf::from("test.md")); 189 assert_eq!(theme, Some("dark".to_string())); 190 } 191 _ => panic!("Expected Present command"), 192 } 193 } 194 195 #[test] 196 fn cli_print_command() { 197 let cli = ArgParser::parse_from(["slides", "print", "test.md", "-w", "100"]); 198 match cli.command { 199 Commands::Print { file, width, theme } => { 200 assert_eq!(file, PathBuf::from("test.md")); 201 assert_eq!(width, 100); 202 assert_eq!(theme, None); 203 } 204 _ => panic!("Expected Print command"), 205 } 206 } 207 208 #[test] 209 fn cli_init_command() { 210 let cli = ArgParser::parse_from(["slides", "init", "--name", "my-deck.md"]); 211 match cli.command { 212 Commands::Init { path, name } => { 213 assert_eq!(path, PathBuf::from(".")); 214 assert_eq!(name, "my-deck.md"); 215 } 216 _ => panic!("Expected Init command"), 217 } 218 } 219 220 #[test] 221 fn cli_check_command() { 222 let cli = ArgParser::parse_from(["slides", "check", "test.md", "--strict"]); 223 match cli.command { 224 Commands::Check { file, strict } => { 225 assert_eq!(file, PathBuf::from("test.md")); 226 assert!(strict); 227 } 228 _ => panic!("Expected Check command"), 229 } 230 } 231 232 #[test] 233 fn run_print_with_test_file() { 234 let temp_dir = std::env::temp_dir(); 235 let test_file = temp_dir.join("test_slides.md"); 236 237 let content = "# Test Slide\n\nThis is a test paragraph.\n\n---\n\n# Second Slide\n\n- Item 1\n- Item 2"; 238 std::fs::write(&test_file, content).expect("Failed to write test file"); 239 240 let result = run_print(&test_file, 80, None); 241 assert!(result.is_ok()); 242 243 std::fs::remove_file(&test_file).ok(); 244 } 245 246 #[test] 247 fn run_print_empty_file() { 248 let temp_dir = std::env::temp_dir(); 249 let test_file = temp_dir.join("empty_slides.md"); 250 251 std::fs::write(&test_file, "").expect("Failed to write test file"); 252 253 let result = run_print(&test_file, 80, None); 254 assert!(result.is_err()); 255 256 std::fs::remove_file(&test_file).ok(); 257 } 258 259 #[test] 260 fn run_print_nonexistent_file() { 261 let test_file = PathBuf::from("/nonexistent/file.md"); 262 let result = run_print(&test_file, 80, None); 263 assert!(result.is_err()); 264 } 265}