magical markdown slides

feat(rendering): add syntax highlighting and Unicode heading symbols

+548 -42
+253 -2
Cargo.lock
··· 3 3 version = 4 4 4 5 5 [[package]] 6 + name = "adler2" 7 + version = "2.0.1" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 10 + 11 + [[package]] 6 12 name = "allocator-api2" 7 13 version = "0.2.21" 8 14 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 65 71 checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 66 72 67 73 [[package]] 74 + name = "base64" 75 + version = "0.22.1" 76 + source = "registry+https://github.com/rust-lang/crates.io-index" 77 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 78 + 79 + [[package]] 80 + name = "bincode" 81 + version = "1.3.3" 82 + source = "registry+https://github.com/rust-lang/crates.io-index" 83 + checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" 84 + dependencies = [ 85 + "serde", 86 + ] 87 + 88 + [[package]] 68 89 name = "bitflags" 69 90 version = "2.9.4" 70 91 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 83 104 checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" 84 105 dependencies = [ 85 106 "rustversion", 107 + ] 108 + 109 + [[package]] 110 + name = "cc" 111 + version = "1.2.46" 112 + source = "registry+https://github.com/rust-lang/crates.io-index" 113 + checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" 114 + dependencies = [ 115 + "find-msvc-tools", 116 + "shlex", 86 117 ] 87 118 88 119 [[package]] ··· 161 192 ] 162 193 163 194 [[package]] 195 + name = "crc32fast" 196 + version = "1.5.0" 197 + source = "registry+https://github.com/rust-lang/crates.io-index" 198 + checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 199 + dependencies = [ 200 + "cfg-if", 201 + ] 202 + 203 + [[package]] 164 204 name = "crossterm" 165 205 version = "0.28.1" 166 206 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 239 279 ] 240 280 241 281 [[package]] 282 + name = "deranged" 283 + version = "0.5.5" 284 + source = "registry+https://github.com/rust-lang/crates.io-index" 285 + checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" 286 + dependencies = [ 287 + "powerfmt", 288 + ] 289 + 290 + [[package]] 242 291 name = "derive_more" 243 292 version = "2.0.1" 244 293 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 287 336 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 288 337 dependencies = [ 289 338 "libc", 290 - "windows-sys 0.60.2", 339 + "windows-sys 0.61.2", 340 + ] 341 + 342 + [[package]] 343 + name = "find-msvc-tools" 344 + version = "0.1.5" 345 + source = "registry+https://github.com/rust-lang/crates.io-index" 346 + checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" 347 + 348 + [[package]] 349 + name = "flate2" 350 + version = "1.1.5" 351 + source = "registry+https://github.com/rust-lang/crates.io-index" 352 + checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" 353 + dependencies = [ 354 + "crc32fast", 355 + "miniz_oxide", 291 356 ] 292 357 293 358 [[package]] ··· 407 472 ] 408 473 409 474 [[package]] 475 + name = "linked-hash-map" 476 + version = "0.5.6" 477 + source = "registry+https://github.com/rust-lang/crates.io-index" 478 + checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 479 + 480 + [[package]] 410 481 name = "linux-raw-sys" 411 482 version = "0.4.15" 412 483 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 455 526 checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 456 527 457 528 [[package]] 529 + name = "miniz_oxide" 530 + version = "0.8.9" 531 + source = "registry+https://github.com/rust-lang/crates.io-index" 532 + checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 533 + dependencies = [ 534 + "adler2", 535 + "simd-adler32", 536 + ] 537 + 538 + [[package]] 458 539 name = "mio" 459 540 version = "1.1.0" 460 541 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 474 555 dependencies = [ 475 556 "windows-sys 0.52.0", 476 557 ] 558 + 559 + [[package]] 560 + name = "num-conv" 561 + version = "0.1.0" 562 + source = "registry+https://github.com/rust-lang/crates.io-index" 563 + checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 477 564 478 565 [[package]] 479 566 name = "once_cell" ··· 488 575 checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" 489 576 490 577 [[package]] 578 + name = "onig" 579 + version = "6.5.1" 580 + source = "registry+https://github.com/rust-lang/crates.io-index" 581 + checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" 582 + dependencies = [ 583 + "bitflags", 584 + "libc", 585 + "once_cell", 586 + "onig_sys", 587 + ] 588 + 589 + [[package]] 590 + name = "onig_sys" 591 + version = "69.9.1" 592 + source = "registry+https://github.com/rust-lang/crates.io-index" 593 + checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" 594 + dependencies = [ 595 + "cc", 596 + "pkg-config", 597 + ] 598 + 599 + [[package]] 491 600 name = "owo-colors" 492 601 version = "4.2.3" 493 602 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 529 638 checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 530 639 531 640 [[package]] 641 + name = "pkg-config" 642 + version = "0.3.32" 643 + source = "registry+https://github.com/rust-lang/crates.io-index" 644 + checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 645 + 646 + [[package]] 647 + name = "plist" 648 + version = "1.8.0" 649 + source = "registry+https://github.com/rust-lang/crates.io-index" 650 + checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" 651 + dependencies = [ 652 + "base64", 653 + "indexmap", 654 + "quick-xml", 655 + "serde", 656 + "time", 657 + ] 658 + 659 + [[package]] 660 + name = "powerfmt" 661 + version = "0.2.0" 662 + source = "registry+https://github.com/rust-lang/crates.io-index" 663 + checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 664 + 665 + [[package]] 532 666 name = "proc-macro2" 533 667 version = "1.0.101" 534 668 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 557 691 checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" 558 692 559 693 [[package]] 694 + name = "quick-xml" 695 + version = "0.38.4" 696 + source = "registry+https://github.com/rust-lang/crates.io-index" 697 + checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" 698 + dependencies = [ 699 + "memchr", 700 + ] 701 + 702 + [[package]] 560 703 name = "quote" 561 704 version = "1.0.41" 562 705 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 596 739 ] 597 740 598 741 [[package]] 742 + name = "regex-syntax" 743 + version = "0.8.8" 744 + source = "registry+https://github.com/rust-lang/crates.io-index" 745 + checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 746 + 747 + [[package]] 599 748 name = "rustix" 600 749 version = "0.38.44" 601 750 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 618 767 "errno", 619 768 "libc", 620 769 "linux-raw-sys 0.11.0", 621 - "windows-sys 0.60.2", 770 + "windows-sys 0.61.2", 622 771 ] 623 772 624 773 [[package]] ··· 634 783 checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 635 784 636 785 [[package]] 786 + name = "same-file" 787 + version = "1.0.6" 788 + source = "registry+https://github.com/rust-lang/crates.io-index" 789 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 790 + dependencies = [ 791 + "winapi-util", 792 + ] 793 + 794 + [[package]] 637 795 name = "scopeguard" 638 796 version = "1.2.0" 639 797 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 714 872 dependencies = [ 715 873 "lazy_static", 716 874 ] 875 + 876 + [[package]] 877 + name = "shlex" 878 + version = "1.3.0" 879 + source = "registry+https://github.com/rust-lang/crates.io-index" 880 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 717 881 718 882 [[package]] 719 883 name = "signal-hook" ··· 746 910 ] 747 911 748 912 [[package]] 913 + name = "simd-adler32" 914 + version = "0.3.7" 915 + source = "registry+https://github.com/rust-lang/crates.io-index" 916 + checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" 917 + 918 + [[package]] 749 919 name = "slides-cli" 750 920 version = "0.1.0" 751 921 dependencies = [ ··· 769 939 "serde", 770 940 "serde_json", 771 941 "serde_yml", 942 + "syntect", 772 943 "terminal-colorsaurus", 773 944 "thiserror", 774 945 "toml", ··· 837 1008 ] 838 1009 839 1010 [[package]] 1011 + name = "syntect" 1012 + version = "5.3.0" 1013 + source = "registry+https://github.com/rust-lang/crates.io-index" 1014 + checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" 1015 + dependencies = [ 1016 + "bincode", 1017 + "flate2", 1018 + "fnv", 1019 + "once_cell", 1020 + "onig", 1021 + "plist", 1022 + "regex-syntax", 1023 + "serde", 1024 + "serde_derive", 1025 + "serde_json", 1026 + "thiserror", 1027 + "walkdir", 1028 + "yaml-rust", 1029 + ] 1030 + 1031 + [[package]] 840 1032 name = "terminal-colorsaurus" 841 1033 version = "1.0.1" 842 1034 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 892 1084 ] 893 1085 894 1086 [[package]] 1087 + name = "time" 1088 + version = "0.3.44" 1089 + source = "registry+https://github.com/rust-lang/crates.io-index" 1090 + checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" 1091 + dependencies = [ 1092 + "deranged", 1093 + "itoa", 1094 + "num-conv", 1095 + "powerfmt", 1096 + "serde", 1097 + "time-core", 1098 + "time-macros", 1099 + ] 1100 + 1101 + [[package]] 1102 + name = "time-core" 1103 + version = "0.1.6" 1104 + source = "registry+https://github.com/rust-lang/crates.io-index" 1105 + checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" 1106 + 1107 + [[package]] 1108 + name = "time-macros" 1109 + version = "0.2.24" 1110 + source = "registry+https://github.com/rust-lang/crates.io-index" 1111 + checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" 1112 + dependencies = [ 1113 + "num-conv", 1114 + "time-core", 1115 + ] 1116 + 1117 + [[package]] 895 1118 name = "toml" 896 1119 version = "0.9.7" 897 1120 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1047 1270 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 1048 1271 1049 1272 [[package]] 1273 + name = "walkdir" 1274 + version = "2.5.0" 1275 + source = "registry+https://github.com/rust-lang/crates.io-index" 1276 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 1277 + dependencies = [ 1278 + "same-file", 1279 + "winapi-util", 1280 + ] 1281 + 1282 + [[package]] 1050 1283 name = "wasi" 1051 1284 version = "0.11.1+wasi-snapshot-preview1" 1052 1285 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1069 1302 checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1070 1303 1071 1304 [[package]] 1305 + name = "winapi-util" 1306 + version = "0.1.11" 1307 + source = "registry+https://github.com/rust-lang/crates.io-index" 1308 + checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 1309 + dependencies = [ 1310 + "windows-sys 0.61.2", 1311 + ] 1312 + 1313 + [[package]] 1072 1314 name = "winapi-x86_64-pc-windows-gnu" 1073 1315 version = "0.4.0" 1074 1316 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1256 1498 version = "1.0.1" 1257 1499 source = "registry+https://github.com/rust-lang/crates.io-index" 1258 1500 checksum = "4de5f056fb9dc8b7908754867544e26145767187aaac5a98495e88ad7cb8a80f" 1501 + 1502 + [[package]] 1503 + name = "yaml-rust" 1504 + version = "0.4.5" 1505 + source = "registry+https://github.com/rust-lang/crates.io-index" 1506 + checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" 1507 + dependencies = [ 1508 + "linked-hash-map", 1509 + ]
+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 + syntect = "5" 14 15 terminal-colorsaurus = "1.0.1" 15 16 thiserror = "2.0.17" 16 17 toml = "0.9.7"
+238
core/src/highlighter.rs
··· 1 + use std::sync::OnceLock; 2 + use syntect::easy::HighlightLines; 3 + use syntect::highlighting::{Theme, ThemeSet}; 4 + use syntect::parsing::SyntaxSet; 5 + use syntect::util::LinesWithEndings; 6 + 7 + use crate::theme::{Color, ThemeColors}; 8 + 9 + /// Global syntax set (lazy-initialized) 10 + static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new(); 11 + 12 + /// Global theme set (lazy-initialized) 13 + static THEME_SET: OnceLock<ThemeSet> = OnceLock::new(); 14 + 15 + /// Get the global syntax set 16 + pub fn syntax_set() -> &'static SyntaxSet { 17 + SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines) 18 + } 19 + 20 + /// Get the global theme set 21 + pub fn theme_set() -> &'static ThemeSet { 22 + THEME_SET.get_or_init(ThemeSet::load_defaults) 23 + } 24 + 25 + /// A highlighted token with text and color 26 + #[derive(Debug, Clone)] 27 + pub struct HighlightedToken { 28 + pub text: String, 29 + pub color: Color, 30 + } 31 + 32 + /// Highlight code using syntect and map to theme colors 33 + /// 34 + /// Returns a vector of lines, where each line is a vector of highlighted tokens. 35 + /// If the language is not recognized or highlighting fails, returns the code with default styling. 36 + pub fn highlight_code(code: &str, language: Option<&str>, theme_colors: &ThemeColors) -> Vec<Vec<HighlightedToken>> { 37 + let ss = syntax_set(); 38 + 39 + let syntax = language 40 + .and_then(|lang| ss.find_syntax_by_token(lang)) 41 + .unwrap_or_else(|| ss.find_syntax_plain_text()); 42 + 43 + let syntect_theme = get_syntect_theme(theme_colors); 44 + 45 + let mut highlighter = HighlightLines::new(syntax, syntect_theme); 46 + let mut result = Vec::new(); 47 + 48 + for line in LinesWithEndings::from(code) { 49 + let Ok(ranges) = highlighter.highlight_line(line, ss) else { 50 + result.push(vec![HighlightedToken { 51 + text: line.to_string(), 52 + color: theme_colors.code, 53 + }]); 54 + continue; 55 + }; 56 + 57 + let mut tokens = Vec::new(); 58 + for (style, text) in ranges { 59 + let color = Color::from_syntect(style.foreground); 60 + tokens.push(HighlightedToken { text: text.to_string(), color }); 61 + } 62 + result.push(tokens); 63 + } 64 + 65 + result 66 + } 67 + 68 + /// Get the appropriate syntect theme based on the current theme 69 + fn get_syntect_theme(theme_colors: &ThemeColors) -> &'static Theme { 70 + let ts = theme_set(); 71 + let is_dark = is_dark_theme(theme_colors); 72 + 73 + if is_dark { 74 + ts.themes 75 + .get("base16-ocean.dark") 76 + .or_else(|| ts.themes.get("Solarized (dark)")) 77 + .or_else(|| ts.themes.get("base16-mocha.dark")) 78 + .unwrap_or_else(|| ts.themes.values().next().unwrap()) 79 + } else { 80 + ts.themes 81 + .get("base16-ocean.light") 82 + .or_else(|| ts.themes.get("Solarized (light)")) 83 + .or_else(|| ts.themes.get("InspiredGitHub")) 84 + .unwrap_or_else(|| ts.themes.values().next().unwrap()) 85 + } 86 + } 87 + 88 + /// Detect if a theme is dark based on its colors 89 + fn is_dark_theme(theme_colors: &ThemeColors) -> bool { 90 + let body = theme_colors.body; 91 + let luminance = 0.299 * body.r as f32 + 0.587 * body.g as f32 + 0.114 * body.b as f32; 92 + luminance > 128.0 93 + } 94 + 95 + impl Color { 96 + /// Create a Color from syntect's RGB color 97 + pub fn from_syntect(color: syntect::highlighting::Color) -> Self { 98 + Self { r: color.r, g: color.g, b: color.b } 99 + } 100 + } 101 + 102 + #[cfg(test)] 103 + mod tests { 104 + use super::*; 105 + 106 + #[test] 107 + fn syntax_set_loads_successfully() { 108 + let ss = syntax_set(); 109 + assert!(!ss.syntaxes().is_empty()); 110 + } 111 + 112 + #[test] 113 + fn theme_set_loads_successfully() { 114 + let ts = theme_set(); 115 + assert!(!ts.themes.is_empty()); 116 + } 117 + 118 + #[test] 119 + fn highlight_code_with_rust_syntax() { 120 + let code = "fn main() {\n println!(\"Hello\");\n}"; 121 + let theme = ThemeColors::default(); 122 + let result = highlight_code(code, Some("rust"), &theme); 123 + 124 + assert_eq!(result.len(), 3); 125 + assert!(!result[0].is_empty()); 126 + assert!(!result[1].is_empty()); 127 + assert!(!result[2].is_empty()); 128 + } 129 + 130 + #[test] 131 + fn highlight_code_with_unknown_language() { 132 + let code = "some random text"; 133 + let theme = ThemeColors::default(); 134 + let result = highlight_code(code, Some("unknown-lang-xyz"), &theme); 135 + assert_eq!(result.len(), 1); 136 + assert!(!result[0].is_empty()); 137 + } 138 + 139 + #[test] 140 + fn highlight_code_without_language() { 141 + let code = "plain text\nno highlighting"; 142 + let theme = ThemeColors::default(); 143 + let result = highlight_code(code, None, &theme); 144 + assert_eq!(result.len(), 2); 145 + assert!(!result[0].is_empty()); 146 + assert!(!result[1].is_empty()); 147 + } 148 + 149 + #[test] 150 + fn highlight_code_empty_string() { 151 + let theme = ThemeColors::default(); 152 + let result = highlight_code("", Some("rust"), &theme); 153 + assert!(result.is_empty() || (result.len() == 1 && result[0].is_empty())); 154 + } 155 + 156 + #[test] 157 + fn highlight_code_with_python_syntax() { 158 + let code = "def hello():\n print(\"world\")"; 159 + let theme = ThemeColors::default(); 160 + let result = highlight_code(code, Some("python"), &theme); 161 + 162 + assert_eq!(result.len(), 2); 163 + assert!(!result[0].is_empty()); 164 + assert!(!result[1].is_empty()); 165 + } 166 + 167 + #[test] 168 + fn highlight_code_preserves_line_count() { 169 + let code = "line1\nline2\nline3\nline4"; 170 + let theme = ThemeColors::default(); 171 + let result = highlight_code(code, Some("rust"), &theme); 172 + assert_eq!(result.len(), 4); 173 + } 174 + 175 + #[test] 176 + fn color_from_syntect_conversion() { 177 + let syntect_color = syntect::highlighting::Color { r: 255, g: 128, b: 64, a: 255 }; 178 + let color = Color::from_syntect(syntect_color); 179 + 180 + assert_eq!(color.r, 255); 181 + assert_eq!(color.g, 128); 182 + assert_eq!(color.b, 64); 183 + } 184 + 185 + #[test] 186 + fn is_dark_theme_detects_dark() { 187 + let dark_theme = ThemeColors { 188 + heading: Color::new(200, 200, 200), 189 + heading_bold: true, 190 + body: Color::new(180, 180, 180), 191 + accent: Color::new(100, 150, 200), 192 + code: Color::new(150, 150, 150), 193 + dimmed: Color::new(100, 100, 100), 194 + code_fence: Color::new(120, 120, 120), 195 + rule: Color::new(100, 100, 100), 196 + list_marker: Color::new(150, 150, 150), 197 + blockquote_border: Color::new(120, 120, 120), 198 + table_border: Color::new(120, 120, 120), 199 + }; 200 + 201 + assert!(is_dark_theme(&dark_theme)); 202 + } 203 + 204 + #[test] 205 + fn is_dark_theme_detects_light() { 206 + let light_theme = ThemeColors { 207 + heading: Color::new(50, 50, 50), 208 + heading_bold: true, 209 + body: Color::new(30, 30, 30), 210 + accent: Color::new(0, 100, 200), 211 + code: Color::new(60, 60, 60), 212 + dimmed: Color::new(100, 100, 100), 213 + code_fence: Color::new(80, 80, 80), 214 + rule: Color::new(100, 100, 100), 215 + list_marker: Color::new(50, 50, 50), 216 + blockquote_border: Color::new(80, 80, 80), 217 + table_border: Color::new(80, 80, 80), 218 + }; 219 + 220 + assert!(!is_dark_theme(&light_theme)); 221 + } 222 + 223 + #[test] 224 + fn get_syntect_theme_returns_valid_theme() { 225 + let theme = ThemeColors::default(); 226 + let syntect_theme = get_syntect_theme(&theme); 227 + assert!(syntect_theme.settings.background.is_some() || syntect_theme.settings.foreground.is_some()); 228 + } 229 + 230 + #[test] 231 + fn highlight_code_handles_multiline_strings() { 232 + let code = r#"let s = "hello 233 + world";"#; 234 + let theme = ThemeColors::default(); 235 + let result = highlight_code(code, Some("rust"), &theme); 236 + assert_eq!(result.len(), 2); 237 + } 238 + }
+1
core/src/lib.rs
··· 1 1 pub mod error; 2 + pub mod highlighter; 2 3 pub mod metadata; 3 4 pub mod parser; 4 5 pub mod printer;
+28 -12
core/src/printer.rs
··· 1 + use crate::highlighter; 1 2 use crate::slide::{Block, CodeBlock, List, Table, TextSpan, TextStyle}; 2 3 use crate::theme::ThemeColors; 3 4 ··· 76 77 Ok(()) 77 78 } 78 79 79 - /// Print a heading with level-appropriate styling 80 + /// Print a heading with level-appropriate styling using Unicode block symbols 80 81 fn print_heading<W: std::io::Write>( 81 82 writer: &mut W, level: u8, spans: &[TextSpan], theme: &ThemeColors, 82 83 ) -> std::io::Result<()> { 83 84 let prefix = match level { 84 - 1 => "# ", 85 - 2 => "## ", 86 - 3 => "### ", 87 - 4 => "#### ", 88 - 5 => "##### ", 89 - _ => "###### ", 85 + 1 => "▉ ", 86 + 2 => "▓ ", 87 + 3 => "▒ ", 88 + 4 => "░ ", 89 + _ => "▌ ", 90 90 }; 91 91 92 92 write!(writer, "{}", theme.heading(&prefix))?; ··· 144 144 Ok(()) 145 145 } 146 146 147 - /// Print a code block with language tag 147 + /// Print a code block with syntax highlighting 148 148 fn print_code_block<W: std::io::Write>( 149 149 writer: &mut W, code: &CodeBlock, theme: &ThemeColors, width: usize, 150 150 ) -> std::io::Result<()> { ··· 154 154 writeln!(writer, "{}", theme.code_fence(&"```"))?; 155 155 } 156 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))?; 157 + let highlighted_lines = highlighter::highlight_code(&code.code, code.language.as_deref(), theme); 158 + 159 + for tokens in highlighted_lines { 160 + let mut line_length = 0; 161 + for token in tokens { 162 + if line_length + token.text.len() > width - 4 { 163 + let remaining = (width - 4).saturating_sub(line_length); 164 + if remaining > 0 { 165 + let trimmed = &token.text[..remaining.min(token.text.len())]; 166 + write!(writer, "{}", token.color.to_owo_color(&trimmed))?; 167 + } 168 + break; 169 + } 170 + write!(writer, "{}", token.color.to_owo_color(&token.text))?; 171 + line_length += token.text.len(); 172 + } 173 + writeln!(writer)?; 160 174 } 161 175 162 176 writeln!(writer, "{}", theme.code_fence(&"```"))?; ··· 409 423 410 424 let result = print_slides(&mut output, &[slide], &theme, 80); 411 425 assert!(result.is_ok()); 426 + 412 427 let text = String::from_utf8_lossy(&output); 413 428 assert!(text.contains("```rust")); 414 - assert!(text.contains("fn main()")); 429 + assert!(text.contains("fn") && text.contains("main")); 430 + assert!(text.contains("println")); 415 431 } 416 432 417 433 #[test]
+5
core/src/theme.rs
··· 13 13 pub const fn new(r: u8, g: u8, b: u8) -> Self { 14 14 Self { r, g, b } 15 15 } 16 + 17 + /// Apply this color to text using owo-colors 18 + pub fn to_owo_color<'a, T: OwoColorize>(&self, text: &'a T) -> owo_colors::Styled<&'a T> { 19 + text.style(self.into()) 20 + } 16 21 } 17 22 18 23 impl From<Color> for Style {
+22 -28
ui/src/renderer.rs
··· 3 3 text::{Line, Span, Text}, 4 4 }; 5 5 use slides_core::{ 6 + highlighter, 6 7 slide::{Block, CodeBlock, List, Table, TextSpan, TextStyle}, 7 8 theme::ThemeColors, 8 9 }; ··· 30 31 Text::from(lines) 31 32 } 32 33 33 - /// Get heading prefix 34 + /// Get heading prefix using Unicode block symbols 34 35 fn get_prefix(level: u8) -> &'static str { 35 36 match level { 36 - 1 => "# ", 37 - 2 => "## ", 38 - 3 => "### ", 39 - 4 => "#### ", 40 - 5 => "##### ", 41 - _ => "###### ", 37 + 1 => "▉ ", // Large block / heavy fill (U+2589) 38 + 2 => "▓ ", // Dark shade (U+2593) 39 + 3 => "▒ ", // Medium shade (U+2592) 40 + 4 => "░ ", // Light shade (U+2591) 41 + 5 => "▌ ", // Left half block (U+258C) 42 + _ => "▌ ", // Left half block (U+258C) for h6 42 43 } 43 44 } 44 45 ··· 61 62 lines.push(Line::from(line_spans)); 62 63 } 63 64 64 - /// Render a code block with monospace styling 65 + /// Render a code block with syntax highlighting 65 66 fn render_code_block(code: &CodeBlock, theme: &ThemeColors, lines: &mut Vec<Line<'static>>) { 66 67 let fence_style = to_ratatui_style(&theme.code_fence, false); 67 - let code_style = to_ratatui_style(&theme.code, false); 68 68 69 69 if let Some(lang) = &code.language { 70 70 lines.push(Line::from(Span::styled(format!("```{}", lang), fence_style))); ··· 72 72 lines.push(Line::from(Span::styled("```".to_string(), fence_style))); 73 73 } 74 74 75 - for line in code.code.lines() { 76 - lines.push(Line::from(Span::styled(line.to_string(), code_style))); 75 + let highlighted_lines = highlighter::highlight_code(&code.code, code.language.as_deref(), theme); 76 + 77 + for tokens in highlighted_lines { 78 + let mut line_spans = Vec::new(); 79 + for token in tokens { 80 + let token_style = to_ratatui_style(&token.color, false); 81 + line_spans.push(Span::styled(token.text, token_style)); 82 + } 83 + lines.push(Line::from(line_spans)); 77 84 } 78 85 79 86 lines.push(Line::from(Span::styled("```".to_string(), fence_style))); ··· 273 280 let color = Color::new(255, 128, 64); 274 281 let style = to_ratatui_style(&color, false); 275 282 276 - assert_eq!( 277 - style.fg, 278 - Some(ratatui::style::Color::Rgb(255, 128, 64)) 279 - ); 283 + assert_eq!(style.fg, Some(ratatui::style::Color::Rgb(255, 128, 64))); 280 284 } 281 285 282 286 #[test] ··· 286 290 let color = Color::new(100, 150, 200); 287 291 let style = to_ratatui_style(&color, true); 288 292 289 - assert_eq!( 290 - style.fg, 291 - Some(ratatui::style::Color::Rgb(100, 150, 200)) 292 - ); 293 + assert_eq!(style.fg, Some(ratatui::style::Color::Rgb(100, 150, 200))); 293 294 assert!(style.add_modifier.contains(Modifier::BOLD)); 294 295 } 295 296 ··· 306 307 #[test] 307 308 fn render_heading_uses_theme_colors() { 308 309 let theme = ThemeColors::default(); 309 - let blocks = vec![Block::Heading { 310 - level: 1, 311 - spans: vec![TextSpan::plain("Colored Heading")], 312 - }]; 310 + let blocks = vec![Block::Heading { level: 1, spans: vec![TextSpan::plain("Colored Heading")] }]; 313 311 314 312 let text = render_slide_content(&blocks, &theme); 315 313 assert!(!text.lines.is_empty()); ··· 334 332 let style = apply_theme_style(&theme, &text_style, false); 335 333 assert_eq!( 336 334 style.fg, 337 - Some(ratatui::style::Color::Rgb( 338 - theme.code.r, 339 - theme.code.g, 340 - theme.code.b 341 - )) 335 + Some(ratatui::style::Color::Rgb(theme.code.r, theme.code.g, theme.code.b)) 342 336 ); 343 337 } 344 338 }