magical markdown slides

feat: implement theme registry and improve table rendering

* added theme registry with 7 built-in themes (default, dark, light, monokai, dracula, solarized_dark, nord)
* automatic column width calculation for tables with proper Unicode borders
* Update CLI to load themes from registry based on frontmatter, CLI flags, or environment variables
* drafted theme documentation with examples and descriptions

+775 -46
+49 -7
Cargo.lock
··· 456 456 457 457 [[package]] 458 458 name = "mio" 459 - version = "1.0.4" 459 + version = "1.1.0" 460 460 source = "registry+https://github.com/rust-lang/crates.io-index" 461 - checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" 461 + checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" 462 462 dependencies = [ 463 463 "libc", 464 464 "log", 465 465 "wasi", 466 - "windows-sys 0.59.0", 466 + "windows-sys 0.61.2", 467 467 ] 468 468 469 469 [[package]] ··· 727 727 728 728 [[package]] 729 729 name = "signal-hook-mio" 730 - version = "0.2.4" 730 + version = "0.2.5" 731 731 source = "registry+https://github.com/rust-lang/crates.io-index" 732 - checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" 732 + checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" 733 733 dependencies = [ 734 734 "libc", 735 735 "mio", ··· 769 769 "serde", 770 770 "serde_json", 771 771 "serde_yml", 772 + "terminal-colorsaurus", 772 773 "thiserror", 773 774 "toml", 774 775 "tracing", ··· 836 837 ] 837 838 838 839 [[package]] 840 + name = "terminal-colorsaurus" 841 + version = "1.0.1" 842 + source = "registry+https://github.com/rust-lang/crates.io-index" 843 + checksum = "8909f33134da34b43f69145e748790de650a6abd84faf1f82e773444dd293ec8" 844 + dependencies = [ 845 + "cfg-if", 846 + "libc", 847 + "memchr", 848 + "mio", 849 + "terminal-trx", 850 + "windows-sys 0.61.2", 851 + "xterm-color", 852 + ] 853 + 854 + [[package]] 855 + name = "terminal-trx" 856 + version = "0.2.5" 857 + source = "registry+https://github.com/rust-lang/crates.io-index" 858 + checksum = "662a3cd5ca570df622e848ef18b50c151e65c9835257465417242243b0bce783" 859 + dependencies = [ 860 + "cfg-if", 861 + "libc", 862 + "windows-sys 0.61.2", 863 + ] 864 + 865 + [[package]] 839 866 name = "thiserror" 840 867 version = "2.0.17" 841 868 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1049 1076 1050 1077 [[package]] 1051 1078 name = "windows-link" 1052 - version = "0.2.0" 1079 + version = "0.2.1" 1053 1080 source = "registry+https://github.com/rust-lang/crates.io-index" 1054 - checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" 1081 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 1055 1082 1056 1083 [[package]] 1057 1084 name = "windows-sys" ··· 1078 1105 checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 1079 1106 dependencies = [ 1080 1107 "windows-targets 0.53.4", 1108 + ] 1109 + 1110 + [[package]] 1111 + name = "windows-sys" 1112 + version = "0.61.2" 1113 + source = "registry+https://github.com/rust-lang/crates.io-index" 1114 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 1115 + dependencies = [ 1116 + "windows-link", 1081 1117 ] 1082 1118 1083 1119 [[package]] ··· 1214 1250 version = "0.7.13" 1215 1251 source = "registry+https://github.com/rust-lang/crates.io-index" 1216 1252 checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" 1253 + 1254 + [[package]] 1255 + name = "xterm-color" 1256 + version = "1.0.1" 1257 + source = "registry+https://github.com/rust-lang/crates.io-index" 1258 + checksum = "4de5f056fb9dc8b7908754867544e26145767187aaac5a98495e88ad7cb8a80f"
+1 -1
README.md
··· 8 8 All color use flows through typed wrappers using `owo-colors`. No ad-hoc ANSI escapes. 9 9 10 10 __Themeable:__ 11 - Themes unify visual (Syntect) and terminal (owo) layers. 11 + Multiple built-in color schemes (basic, monokai, dracula, solarized, nord) with automatic light/dark variant detection based on terminal background. Themes can be selected via frontmatter, CLI flags, or environment variables, with optional explicit variant override using `:light` or `:dark` suffix. 12 12 13 13 __Reproducible:__ 14 14 Everything is reproducible in plain text — decks can render without TUI (using `slides print`).
+31 -4
cli/src/main.rs
··· 1 1 use clap::{Parser, Subcommand}; 2 2 use ratatui::{Terminal, backend::CrosstermBackend}; 3 - use slides_core::{parser::parse_slides_with_meta, term::Terminal as SlideTerminal, theme::ThemeColors}; 3 + use slides_core::{parser::parse_slides_with_meta, term::Terminal as SlideTerminal, theme::ThemeRegistry}; 4 4 use slides_tui::App; 5 5 use std::{io, path::PathBuf}; 6 6 use tracing::Level; ··· 112 112 let theme_name = theme_arg.unwrap_or_else(|| meta.theme.clone()); 113 113 tracing::debug!("Using theme: {}", theme_name); 114 114 115 - let theme = ThemeColors::default(); 115 + let theme = ThemeRegistry::get(&theme_name); 116 116 117 117 let filename = file 118 118 .file_name() ··· 156 156 let theme_name = theme_arg.unwrap_or_else(|| meta.theme.clone()); 157 157 tracing::debug!("Using theme: {}", theme_name); 158 158 159 - // TODO: Load theme from theme registry based on theme_name 160 - let theme = ThemeColors::default(); 159 + let theme = ThemeRegistry::get(&theme_name); 161 160 162 161 slides_core::printer::print_slides_to_stdout(&slides, &theme, width)?; 163 162 ··· 261 260 let test_file = PathBuf::from("/nonexistent/file.md"); 262 261 let result = run_print(&test_file, 80, None); 263 262 assert!(result.is_err()); 263 + } 264 + 265 + #[test] 266 + fn run_print_with_theme_from_frontmatter() { 267 + let temp_dir = std::env::temp_dir(); 268 + let test_file = temp_dir.join("test_themed_slides.md"); 269 + 270 + let content = "---\ntheme: dark\n---\n# Test Slide\n\nThis is a test paragraph."; 271 + std::fs::write(&test_file, content).expect("Failed to write test file"); 272 + 273 + let result = run_print(&test_file, 80, None); 274 + assert!(result.is_ok()); 275 + 276 + std::fs::remove_file(&test_file).ok(); 277 + } 278 + 279 + #[test] 280 + fn run_print_with_theme_override() { 281 + let temp_dir = std::env::temp_dir(); 282 + let test_file = temp_dir.join("test_override_slides.md"); 283 + 284 + let content = "---\ntheme: light\n---\n# Test Slide\n\nThis is a test paragraph."; 285 + std::fs::write(&test_file, content).expect("Failed to write test file"); 286 + 287 + let result = run_print(&test_file, 80, Some("monokai".to_string())); 288 + assert!(result.is_ok()); 289 + 290 + std::fs::remove_file(&test_file).ok(); 264 291 } 265 292 }
+1
core/Cargo.toml
··· 11 11 serde = { version = "1.0.228", features = ["derive"] } 12 12 serde_json = "1.0.145" 13 13 serde_yml = "0.0.12" 14 + terminal-colorsaurus = "1.0.1" 14 15 thiserror = "2.0.17" 15 16 toml = "0.9.7"
+186 -20
core/src/printer.rs
··· 212 212 Ok(()) 213 213 } 214 214 215 - /// Print a table with borders 215 + /// Print a table with borders and proper column width calculation 216 216 /// 217 - /// TODO: Implement proper column width calculation and alignment 217 + /// Calculates column widths based on content and distributes available space 218 218 fn print_table<W: std::io::Write>( 219 219 writer: &mut W, table: &Table, theme: &ThemeColors, width: usize, 220 220 ) -> std::io::Result<()> { 221 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 }; 222 + if col_count == 0 { 223 + return Ok(()); 224 + } 225 + 226 + let col_widths = calculate_column_widths(table, width); 223 227 224 228 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)?; 229 + print_table_row(writer, &table.headers, &col_widths, theme, true)?; 234 230 235 - let separator = "─".repeat(width); 231 + let separator = build_table_separator(&col_widths); 236 232 writeln!(writer, "{}", theme.table_border(&separator))?; 237 233 } 238 234 239 235 for row in &table.rows { 240 - for (idx, cell) in row.iter().enumerate() { 241 - if idx > 0 { 242 - write!(writer, "{}", theme.table_border(&" │ "))?; 236 + print_table_row(writer, row, &col_widths, theme, false)?; 237 + } 238 + 239 + Ok(()) 240 + } 241 + 242 + /// Calculate column widths based on content and available space 243 + fn calculate_column_widths(table: &Table, max_width: usize) -> Vec<usize> { 244 + let col_count = table.headers.len(); 245 + if col_count == 0 { 246 + return vec![]; 247 + } 248 + 249 + let mut col_widths = vec![0; col_count]; 250 + 251 + for (col_idx, header) in table.headers.iter().enumerate() { 252 + let content_len: usize = header.iter().map(|s| s.text.len()).sum(); 253 + col_widths[col_idx] = content_len.max(3); 254 + } 255 + 256 + for row in &table.rows { 257 + for (col_idx, cell) in row.iter().enumerate() { 258 + if col_idx < col_widths.len() { 259 + let content_len = cell.iter().map(|s| s.text.len()).sum(); 260 + col_widths[col_idx] = col_widths[col_idx].max(content_len); 243 261 } 244 - for span in cell { 245 - print_span(writer, span, theme, false)?; 246 - } 262 + } 263 + } 264 + 265 + let separator_width = (col_count - 1) * 3; 266 + let padding_width = col_count * 2; 267 + let available_width = max_width.saturating_sub(separator_width + padding_width); 268 + 269 + let total_content_width: usize = col_widths.iter().sum(); 270 + 271 + if total_content_width > available_width { 272 + let scale_factor = available_width as f64 / total_content_width as f64; 273 + for width in &mut col_widths { 274 + *width = ((*width as f64 * scale_factor).ceil() as usize).max(3); 275 + } 276 + } 277 + 278 + col_widths 279 + } 280 + 281 + /// Build a table separator line with proper column separators 282 + fn build_table_separator(col_widths: &[usize]) -> String { 283 + let mut separator = String::new(); 284 + for (idx, &width) in col_widths.iter().enumerate() { 285 + if idx > 0 { 286 + separator.push_str("─┼─"); 247 287 } 248 - writeln!(writer)?; 288 + separator.push_str(&"─".repeat(width + 2)); 249 289 } 290 + separator 291 + } 292 + 293 + /// Print a single table row with proper padding and alignment 294 + fn print_table_row<W: std::io::Write>( 295 + writer: &mut W, cells: &[Vec<TextSpan>], col_widths: &[usize], theme: &ThemeColors, is_header: bool, 296 + ) -> std::io::Result<()> { 297 + for (idx, cell) in cells.iter().enumerate() { 298 + if idx > 0 { 299 + write!(writer, "{}", theme.table_border(&" │ "))?; 300 + } else { 301 + write!(writer, " ")?; 302 + } 303 + 304 + let col_width = col_widths.get(idx).copied().unwrap_or(10); 305 + let content: String = cell.iter().map(|s| s.text.as_str()).collect(); 306 + let content_len = content.len(); 307 + 308 + for span in cell { 309 + print_span(writer, span, theme, is_header)?; 310 + } 311 + 312 + if content_len < col_width { 313 + write!(writer, "{}", " ".repeat(col_width - content_len))?; 314 + } 315 + 316 + write!(writer, " ")?; 317 + } 318 + writeln!(writer)?; 250 319 251 320 Ok(()) 252 321 } ··· 290 359 mod tests { 291 360 use super::*; 292 361 use crate::slide::Slide; 362 + use crate::slide::{Alignment, Table}; 293 363 294 364 #[test] 295 365 fn print_empty_slides() { ··· 365 435 let text = String::from_utf8_lossy(&output); 366 436 assert!(text.contains("Slide 1")); 367 437 assert!(text.contains("Slide 2")); 438 + } 439 + 440 + #[test] 441 + fn print_table_with_headers() { 442 + let table = Table { 443 + headers: vec![ 444 + vec![TextSpan::plain("Name")], 445 + vec![TextSpan::plain("Age")], 446 + vec![TextSpan::plain("City")], 447 + ], 448 + rows: vec![ 449 + vec![ 450 + vec![TextSpan::plain("Alice")], 451 + vec![TextSpan::plain("30")], 452 + vec![TextSpan::plain("NYC")], 453 + ], 454 + vec![ 455 + vec![TextSpan::plain("Bob")], 456 + vec![TextSpan::plain("25")], 457 + vec![TextSpan::plain("LA")], 458 + ], 459 + ], 460 + alignments: vec![Alignment::Left, Alignment::Left, Alignment::Left], 461 + }; 462 + 463 + let slide = Slide::with_blocks(vec![Block::Table(table)]); 464 + let theme = ThemeColors::default(); 465 + let mut output = Vec::new(); 466 + 467 + let result = print_slides(&mut output, &[slide], &theme, 80); 468 + assert!(result.is_ok()); 469 + 470 + let text = String::from_utf8_lossy(&output); 471 + assert!(text.contains("Name")); 472 + assert!(text.contains("Age")); 473 + assert!(text.contains("City")); 474 + assert!(text.contains("Alice")); 475 + assert!(text.contains("Bob")); 476 + assert!(text.contains("│")); 477 + assert!(text.contains("─")); 478 + } 479 + 480 + #[test] 481 + fn print_table_with_column_width_calculation() { 482 + let table = Table { 483 + headers: vec![vec![TextSpan::plain("Short")], vec![TextSpan::plain("Long Header")]], 484 + rows: vec![ 485 + vec![vec![TextSpan::plain("A")], vec![TextSpan::plain("B")]], 486 + vec![vec![TextSpan::plain("Very Long Content")], vec![TextSpan::plain("X")]], 487 + ], 488 + alignments: vec![Alignment::Left, Alignment::Left], 489 + }; 490 + 491 + let col_widths = calculate_column_widths(&table, 80); 492 + 493 + assert_eq!(col_widths.len(), 2); 494 + assert!(col_widths[0] >= 17); 495 + assert!(col_widths[1] >= 11); 496 + } 497 + 498 + #[test] 499 + fn print_table_empty_headers() { 500 + let table = Table { headers: vec![], rows: vec![], alignments: vec![] }; 501 + 502 + let slide = Slide::with_blocks(vec![Block::Table(table)]); 503 + let theme = ThemeColors::default(); 504 + let mut output = Vec::new(); 505 + 506 + let result = print_slides(&mut output, &[slide], &theme, 80); 507 + assert!(result.is_ok()); 508 + } 509 + 510 + #[test] 511 + fn calculate_column_widths_scales_to_fit() { 512 + let table = Table { 513 + headers: vec![ 514 + vec![TextSpan::plain("A".repeat(50))], 515 + vec![TextSpan::plain("B".repeat(50))], 516 + ], 517 + rows: vec![], 518 + alignments: vec![Alignment::Left, Alignment::Left], 519 + }; 520 + 521 + let col_widths = calculate_column_widths(&table, 40); 522 + let total_width: usize = col_widths.iter().sum(); 523 + 524 + assert!(total_width <= 40); 525 + } 526 + 527 + #[test] 528 + fn build_table_separator_correct_format() { 529 + let col_widths = vec![5, 10, 7]; 530 + let separator = build_table_separator(&col_widths); 531 + 532 + assert!(separator.contains("─┼─")); 533 + assert!(separator.contains("─")); 368 534 } 369 535 }
+366 -12
core/src/theme.rs
··· 1 1 use owo_colors::{OwoColorize, Style}; 2 + use terminal_colorsaurus::{QueryOptions, background_color}; 3 + 4 + /// Detects if the terminal background is dark. 5 + /// 6 + /// Uses [terminal_colorsaurus] to query the terminal background color. 7 + /// Defaults to true (dark) if detection fails. 8 + pub fn detect_is_dark() -> bool { 9 + match background_color(QueryOptions::default()) { 10 + Ok(color) => { 11 + let r = color.r as f32 / 255.0; 12 + let g = color.g as f32 / 255.0; 13 + let b = color.b as f32 / 255.0; 14 + let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; 15 + luminance < 0.5 16 + } 17 + Err(_) => true, 18 + } 19 + } 2 20 3 21 /// Color theme abstraction for slides with owo-colors with semantic roles for consistent theming across the application. 4 22 /// ··· 19 37 20 38 impl Default for ThemeColors { 21 39 fn default() -> Self { 22 - Self { 23 - heading: Style::new().bright_cyan().bold(), 24 - body: Style::new().white(), 25 - accent: Style::new().bright_yellow(), 26 - code: Style::new().bright_green(), 27 - dimmed: Style::new().dimmed(), 28 - code_fence: Style::new().on_black().dimmed(), 29 - rule: Style::new().on_black().dimmed(), 30 - list_marker: Style::new().bright_yellow(), 31 - blockquote_border: Style::new().on_black().dimmed(), 32 - table_border: Style::new().on_black().dimmed(), 33 - } 40 + Self::basic(detect_is_dark()) 34 41 } 35 42 } 36 43 ··· 84 91 pub fn table_border<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { 85 92 text.style(self.table_border) 86 93 } 94 + 95 + /// Create an oxocarbon-based theme. 96 + pub fn basic(is_dark: bool) -> Self { 97 + if is_dark { 98 + Self { 99 + // green 100 + heading: Style::new().truecolor(66, 190, 101).bold(), 101 + body: Style::new().truecolor(242, 244, 248), 102 + // pink 103 + accent: Style::new().truecolor(238, 83, 150), 104 + // blue 105 + code: Style::new().truecolor(51, 177, 255), 106 + // gray 107 + dimmed: Style::new().truecolor(82, 82, 82), 108 + code_fence: Style::new().truecolor(82, 82, 82), 109 + rule: Style::new().truecolor(82, 82, 82), 110 + // light blue 111 + list_marker: Style::new().truecolor(120, 169, 255), 112 + blockquote_border: Style::new().truecolor(82, 82, 82), 113 + table_border: Style::new().truecolor(82, 82, 82), 114 + } 115 + } else { 116 + // Oxocarbon Light variant 117 + Self { 118 + // green 119 + heading: Style::new().truecolor(66, 190, 101).bold(), 120 + body: Style::new().truecolor(57, 57, 57), 121 + // orange 122 + accent: Style::new().truecolor(255, 111, 0), 123 + // blue 124 + code: Style::new().truecolor(15, 98, 254), 125 + // dark gray 126 + dimmed: Style::new().truecolor(22, 22, 22), 127 + code_fence: Style::new().truecolor(22, 22, 22), 128 + rule: Style::new().truecolor(22, 22, 22), 129 + // pink 130 + list_marker: Style::new().truecolor(238, 83, 150), 131 + blockquote_border: Style::new().truecolor(22, 22, 22), 132 + table_border: Style::new().truecolor(22, 22, 22), 133 + } 134 + } 135 + } 136 + 137 + /// Create a Monokai-inspired theme. 138 + /// 139 + /// Dark variant uses classic Monokai colors optimized for dark backgrounds. 140 + /// Light variant uses adjusted colors optimized for light backgrounds. 141 + pub fn monokai(is_dark: bool) -> Self { 142 + if is_dark { 143 + Self { 144 + heading: Style::new().truecolor(249, 38, 114).bold(), // pink 145 + body: Style::new().truecolor(248, 248, 242), // off-white 146 + accent: Style::new().truecolor(230, 219, 116), // yellow 147 + code: Style::new().truecolor(166, 226, 46), // green 148 + dimmed: Style::new().truecolor(117, 113, 94), // brown-gray 149 + code_fence: Style::new().truecolor(117, 113, 94), 150 + rule: Style::new().truecolor(117, 113, 94), 151 + list_marker: Style::new().truecolor(230, 219, 116), 152 + blockquote_border: Style::new().truecolor(117, 113, 94), 153 + table_border: Style::new().truecolor(117, 113, 94), 154 + } 155 + } else { 156 + Self { 157 + heading: Style::new().truecolor(200, 30, 90).bold(), // darker pink 158 + body: Style::new().truecolor(39, 40, 34), // dark gray 159 + accent: Style::new().truecolor(180, 170, 80), // darker yellow 160 + code: Style::new().truecolor(100, 150, 30), // darker green 161 + dimmed: Style::new().truecolor(150, 150, 150), // light gray 162 + code_fence: Style::new().truecolor(150, 150, 150), 163 + rule: Style::new().truecolor(150, 150, 150), 164 + list_marker: Style::new().truecolor(180, 170, 80), 165 + blockquote_border: Style::new().truecolor(150, 150, 150), 166 + table_border: Style::new().truecolor(150, 150, 150), 167 + } 168 + } 169 + } 170 + 171 + /// Create a Dracula-inspired theme. 172 + /// 173 + /// Dark variant uses classic Dracula colors optimized for dark backgrounds. 174 + /// Light variant uses adjusted colors optimized for light backgrounds. 175 + pub fn dracula(is_dark: bool) -> Self { 176 + if is_dark { 177 + Self { 178 + heading: Style::new().truecolor(255, 121, 198).bold(), // pink 179 + body: Style::new().truecolor(248, 248, 242), 180 + accent: Style::new().truecolor(139, 233, 253), // cyan 181 + code: Style::new().truecolor(80, 250, 123), // green 182 + dimmed: Style::new().truecolor(98, 114, 164), 183 + code_fence: Style::new().truecolor(98, 114, 164), 184 + rule: Style::new().truecolor(98, 114, 164), 185 + list_marker: Style::new().truecolor(241, 250, 140), // yellow 186 + blockquote_border: Style::new().truecolor(98, 114, 164), 187 + table_border: Style::new().truecolor(98, 114, 164), 188 + } 189 + } else { 190 + Self { 191 + heading: Style::new().truecolor(200, 80, 160).bold(), // darker pink 192 + body: Style::new().truecolor(40, 42, 54), 193 + accent: Style::new().truecolor(80, 150, 180), // darker cyan 194 + code: Style::new().truecolor(50, 160, 80), // darker green 195 + dimmed: Style::new().truecolor(150, 150, 150), // light gray 196 + code_fence: Style::new().truecolor(150, 150, 150), 197 + rule: Style::new().truecolor(150, 150, 150), 198 + list_marker: Style::new().truecolor(180, 170, 90), // darker yellow 199 + blockquote_border: Style::new().truecolor(150, 150, 150), 200 + table_border: Style::new().truecolor(150, 150, 150), 201 + } 202 + } 203 + } 204 + 205 + /// Create a Solarized theme. 206 + /// 207 + /// Uses Ethan Schoonover's Solarized color palette. 208 + pub fn solarized(is_dark: bool) -> Self { 209 + if is_dark { 210 + Self { 211 + heading: Style::new().truecolor(38, 139, 210).bold(), 212 + body: Style::new().truecolor(131, 148, 150), 213 + accent: Style::new().truecolor(42, 161, 152), 214 + code: Style::new().truecolor(133, 153, 0), 215 + dimmed: Style::new().truecolor(88, 110, 117), 216 + code_fence: Style::new().truecolor(88, 110, 117), 217 + rule: Style::new().truecolor(88, 110, 117), 218 + list_marker: Style::new().truecolor(181, 137, 0), 219 + blockquote_border: Style::new().truecolor(88, 110, 117), 220 + table_border: Style::new().truecolor(88, 110, 117), 221 + } 222 + } else { 223 + Self { 224 + heading: Style::new().truecolor(38, 139, 210).bold(), 225 + body: Style::new().truecolor(101, 123, 131), 226 + accent: Style::new().truecolor(42, 161, 152), 227 + code: Style::new().truecolor(133, 153, 0), 228 + dimmed: Style::new().truecolor(147, 161, 161), 229 + code_fence: Style::new().truecolor(147, 161, 161), 230 + rule: Style::new().truecolor(147, 161, 161), 231 + list_marker: Style::new().truecolor(181, 137, 0), 232 + blockquote_border: Style::new().truecolor(147, 161, 161), 233 + table_border: Style::new().truecolor(147, 161, 161), 234 + } 235 + } 236 + } 237 + 238 + /// Create a Nord theme instance 239 + pub fn nord(is_dark: bool) -> Self { 240 + if is_dark { 241 + Self { 242 + heading: Style::new().truecolor(136, 192, 208).bold(), // nord8 - light blue 243 + body: Style::new().truecolor(216, 222, 233), // nord4 244 + accent: Style::new().truecolor(143, 188, 187), // nord7 - teal 245 + code: Style::new().truecolor(163, 190, 140), // nord14 - green 246 + dimmed: Style::new().truecolor(76, 86, 106), // nord3 247 + code_fence: Style::new().truecolor(76, 86, 106), 248 + rule: Style::new().truecolor(76, 86, 106), 249 + list_marker: Style::new().truecolor(235, 203, 139), // nord13 - yellow 250 + blockquote_border: Style::new().truecolor(76, 86, 106), 251 + table_border: Style::new().truecolor(76, 86, 106), 252 + } 253 + } else { 254 + Self { 255 + heading: Style::new().truecolor(94, 129, 172).bold(), // darker blue 256 + body: Style::new().truecolor(46, 52, 64), 257 + accent: Style::new().truecolor(136, 192, 208), // blue 258 + code: Style::new().truecolor(163, 190, 140), // green 259 + dimmed: Style::new().truecolor(143, 157, 175), 260 + code_fence: Style::new().truecolor(143, 157, 175), 261 + rule: Style::new().truecolor(143, 157, 175), 262 + list_marker: Style::new().truecolor(235, 203, 139), // yellow 263 + blockquote_border: Style::new().truecolor(143, 157, 175), 264 + table_border: Style::new().truecolor(143, 157, 175), 265 + } 266 + } 267 + } 268 + } 269 + 270 + /// Theme registry for loading themes by name with automatic light/dark variant selection. 271 + pub struct ThemeRegistry; 272 + 273 + impl ThemeRegistry { 274 + /// Get a theme by name with automatic variant detection or explicit override. 275 + pub fn get(name: &str) -> ThemeColors { 276 + let (theme_name, explicit_variant) = if let Some((scheme, variant)) = name.split_once(':') { 277 + let is_dark = match variant.to_lowercase().as_str() { 278 + "light" => false, 279 + "dark" => true, 280 + _ => detect_is_dark(), 281 + }; 282 + (scheme, Some(is_dark)) 283 + } else { 284 + (name, None) 285 + }; 286 + 287 + let is_dark = explicit_variant.unwrap_or_else(detect_is_dark); 288 + 289 + match theme_name.to_lowercase().as_str() { 290 + "basic" => ThemeColors::basic(is_dark), 291 + "monokai" => ThemeColors::monokai(is_dark), 292 + "dracula" => ThemeColors::dracula(is_dark), 293 + "solarized" => ThemeColors::solarized(is_dark), 294 + "nord" => ThemeColors::nord(is_dark), 295 + _ => ThemeColors::basic(is_dark), 296 + } 297 + } 298 + 299 + /// List all available theme scheme names. 300 + pub fn available_themes() -> Vec<&'static str> { 301 + vec!["basic", "monokai", "dracula", "solarized", "nord"] 302 + } 87 303 } 88 304 89 305 #[cfg(test)] ··· 107 323 assert!(theme.accent(&"Accent").to_string().contains("Accent")); 108 324 assert!(theme.code(&"Code").to_string().contains("Code")); 109 325 assert!(theme.dimmed(&"Dimmed").to_string().contains("Dimmed")); 326 + } 327 + 328 + #[test] 329 + fn theme_basic_dark_variant() { 330 + let theme = ThemeColors::basic(true); 331 + let text = "Test"; 332 + let styled = theme.heading(&text); 333 + assert!(styled.to_string().contains("Test")); 334 + } 335 + 336 + #[test] 337 + fn theme_basic_light_variant() { 338 + let theme = ThemeColors::basic(false); 339 + let text = "Test"; 340 + let styled = theme.heading(&text); 341 + assert!(styled.to_string().contains("Test")); 342 + } 343 + 344 + #[test] 345 + fn theme_monokai_variants() { 346 + let dark = ThemeColors::monokai(true); 347 + let light = ThemeColors::monokai(false); 348 + assert!(dark.heading(&"Test").to_string().contains("Test")); 349 + assert!(light.heading(&"Test").to_string().contains("Test")); 350 + } 351 + 352 + #[test] 353 + fn theme_dracula_variants() { 354 + let dark = ThemeColors::dracula(true); 355 + let light = ThemeColors::dracula(false); 356 + assert!(dark.heading(&"Test").to_string().contains("Test")); 357 + assert!(light.heading(&"Test").to_string().contains("Test")); 358 + } 359 + 360 + #[test] 361 + fn theme_solarized_variants() { 362 + let dark = ThemeColors::solarized(true); 363 + let light = ThemeColors::solarized(false); 364 + assert!(dark.heading(&"Test").to_string().contains("Test")); 365 + assert!(light.heading(&"Test").to_string().contains("Test")); 366 + } 367 + 368 + #[test] 369 + fn theme_nord_variants() { 370 + let dark = ThemeColors::nord(true); 371 + let light = ThemeColors::nord(false); 372 + assert!(dark.heading(&"Test").to_string().contains("Test")); 373 + assert!(light.heading(&"Test").to_string().contains("Test")); 374 + } 375 + 376 + #[test] 377 + fn theme_registry_get_basic() { 378 + let theme = ThemeRegistry::get("basic"); 379 + let text = "Test"; 380 + let styled = theme.heading(&text); 381 + assert!(styled.to_string().contains("Test")); 382 + } 383 + 384 + #[test] 385 + fn theme_registry_explicit_variant_dark() { 386 + let theme = ThemeRegistry::get("basic:dark"); 387 + let text = "Test"; 388 + let styled = theme.heading(&text); 389 + assert!(styled.to_string().contains("Test")); 390 + } 391 + 392 + #[test] 393 + fn theme_registry_explicit_variant_light() { 394 + let theme = ThemeRegistry::get("solarized:light"); 395 + let text = "Test"; 396 + let styled = theme.heading(&text); 397 + assert!(styled.to_string().contains("Test")); 398 + } 399 + 400 + #[test] 401 + fn theme_registry_get_monokai() { 402 + let theme = ThemeRegistry::get("monokai"); 403 + let text = "Test"; 404 + let styled = theme.heading(&text); 405 + assert!(styled.to_string().contains("Test")); 406 + } 407 + 408 + #[test] 409 + fn theme_registry_get_dracula() { 410 + let theme = ThemeRegistry::get("dracula"); 411 + let text = "Test"; 412 + let styled = theme.heading(&text); 413 + assert!(styled.to_string().contains("Test")); 414 + } 415 + 416 + #[test] 417 + fn theme_registry_get_solarized() { 418 + let theme = ThemeRegistry::get("solarized"); 419 + let text = "Test"; 420 + let styled = theme.heading(&text); 421 + assert!(styled.to_string().contains("Test")); 422 + } 423 + 424 + #[test] 425 + fn theme_registry_get_nord() { 426 + let theme = ThemeRegistry::get("nord"); 427 + let text = "Test"; 428 + let styled = theme.heading(&text); 429 + assert!(styled.to_string().contains("Test")); 430 + } 431 + 432 + #[test] 433 + fn theme_registry_get_unknown_fallback() { 434 + let theme = ThemeRegistry::get("nonexistent"); 435 + let text = "Test"; 436 + let styled = theme.heading(&text); 437 + assert!(styled.to_string().contains("Test")); 438 + } 439 + 440 + #[test] 441 + fn theme_registry_case_insensitive() { 442 + let theme1 = ThemeRegistry::get("BASIC"); 443 + let theme2 = ThemeRegistry::get("basic"); 444 + let text = "Test"; 445 + assert!(theme1.heading(&text).to_string().contains("Test")); 446 + assert!(theme2.heading(&text).to_string().contains("Test")); 447 + } 448 + 449 + #[test] 450 + fn theme_registry_available_themes() { 451 + let themes = ThemeRegistry::available_themes(); 452 + assert!(themes.contains(&"basic")); 453 + assert!(themes.contains(&"monokai")); 454 + assert!(themes.contains(&"dracula")); 455 + assert!(themes.contains(&"solarized")); 456 + assert!(themes.contains(&"nord")); 457 + assert_eq!(themes.len(), 5); 458 + } 459 + 460 + #[test] 461 + fn detect_is_dark_returns_bool() { 462 + let result = detect_is_dark(); 463 + assert!(result == true || result == false); 110 464 } 111 465 }
+1
docs/src/SUMMARY.md
··· 8 8 9 9 # Reference 10 10 11 + - [Themes](./appendices/themes.md) 11 12 - [Symbols](./appendices/symbols.md)
+134
docs/src/appendices/themes.md
··· 1 + # Themes 2 + 3 + slides.rs provides a theme system for customizing the appearance of your presentations. Themes control colors and styling for headings, body text, code blocks, and UI elements. 4 + 5 + ## Automatic Light/Dark Detection 6 + 7 + Each theme automatically detects your terminal background and selects the appropriate light or dark variant. This ensures optimal contrast and readability regardless of your terminal settings. 8 + 9 + ## Available Theme Schemes 10 + 11 + The following color schemes are built-in: 12 + 13 + **basic** (default) - IBM's Oxocarbon color palette with clean, modern styling 14 + 15 + - Dark variant: Light text on dark background with vibrant accents 16 + - Light variant: Dark text on light background with adjusted colors 17 + 18 + **monokai** - Inspired by the popular Monokai editor theme 19 + 20 + - Dark variant: Classic Monokai with pink headings and green code 21 + - Light variant: Adjusted colors for light backgrounds 22 + 23 + **dracula** - Based on the Dracula color scheme 24 + 25 + - Dark variant: Purple and cyan tones optimized for dark backgrounds 26 + - Light variant: Darker variants of Dracula colors for light backgrounds 27 + 28 + **solarized** - Ethan Schoonover's Solarized palette 29 + 30 + - Dark variant: Solarized Dark with blue headings 31 + - Light variant: Solarized Light with adjusted foreground colors 32 + 33 + **nord** - Arctic-inspired theme with cool tones 34 + 35 + - Dark variant: Subtle blues and greens on dark background 36 + - Light variant: Nord colors adjusted for light backgrounds 37 + 38 + ## Using Themes 39 + 40 + ### Via Frontmatter 41 + 42 + Specify a theme in your slide deck's YAML frontmatter: 43 + 44 + ````markdown 45 + --- 46 + theme: monokai 47 + --- 48 + 49 + # Your First Slide 50 + 51 + Content here 52 + ``` 53 + 54 + The terminal background will be automatically detected. To force a specific variant: 55 + 56 + ```markdown 57 + --- 58 + theme: solarized:light 59 + --- 60 + ``` 61 + 62 + Or with TOML frontmatter: 63 + 64 + ```markdown 65 + +++ 66 + theme = "dracula:dark" 67 + +++ 68 + 69 + # Your First Slide 70 + 71 + Content here 72 + ```` 73 + 74 + ### Via Command Line 75 + 76 + Override the theme with the `--theme` flag: 77 + 78 + ```bash 79 + # Auto-detect terminal background 80 + slides present slides.md --theme nord 81 + slides print slides.md --theme solarized 82 + 83 + # Force a specific variant 84 + slides present slides.md --theme monokai:light 85 + slides print slides.md --theme nord:dark 86 + ``` 87 + 88 + ### Via Environment Variable 89 + 90 + Set a default theme using the `SLIDES_THEME` environment variable: 91 + 92 + ```bash 93 + # Auto-detect variant 94 + export SLIDES_THEME=basic 95 + slides present slides.md 96 + 97 + # Force specific variant 98 + export SLIDES_THEME=dracula:dark 99 + slides present slides.md 100 + ``` 101 + 102 + ## Theme Priority 103 + 104 + When multiple theme sources are specified, the priority order is: 105 + 106 + 1. Command line flag (`--theme`) 107 + 2. Frontmatter metadata (`theme:` field) 108 + 3. Environment variable (`SLIDES_THEME`) 109 + 4. Default theme 110 + 111 + ## Theme Components 112 + 113 + Each theme defines colors for: 114 + 115 + - Headings (level 1-6) 116 + - Body text 117 + - Accent colors 118 + - Code blocks and inline code 119 + - Code fence markers 120 + - Horizontal rules 121 + - List markers (bullets and numbers) 122 + - Blockquote borders 123 + - Table borders 124 + 125 + ## Rendering Features 126 + 127 + The printer uses Unicode box-drawing characters for clean visual output: 128 + 129 + - `─` and `═` for horizontal lines 130 + - `│` for vertical borders 131 + - `┼` for table intersections 132 + - `•` for unordered list markers 133 + 134 + Tables automatically calculate column widths based on content and available terminal width.
+6 -2
docs/src/quickstart.md
··· 152 152 - Lists (ordered and unordered with nesting) 153 153 - Horizontal rules 154 154 - Blockquotes 155 - - Tables 155 + - Tables with automatic column width calculation and proper Unicode borders 156 156 157 157 ## Speaker Notes 158 158 ··· 186 186 Customize defaults with environment variables: 187 187 188 188 ```bash 189 - # Set default theme 189 + # Set default theme (options: default, dark, light, monokai, dracula, solarized_dark, nord) 190 190 export SLIDES_THEME=dark 191 191 192 192 # Set default author (used if not in frontmatter) 193 193 export USER=YourName 194 194 ``` 195 + 196 + ## Themes 197 + 198 + See the [Themes](./appendices/themes.md) reference for details on all available themes and customization options.