tui_markdown/
code_theme.rs1use std::error::Error;
13use std::fmt;
14use std::io::Cursor;
15use std::path::{Path, PathBuf};
16use std::sync::LazyLock;
17
18use syntect::highlighting::{Theme, ThemeSet};
19
20#[derive(Clone, Debug)]
31pub struct CodeTheme {
32 theme: Theme,
33}
34
35impl CodeTheme {
36 pub fn from_textmate(source: &str) -> Result<Self, CodeThemeLoadError> {
46 let mut reader = Cursor::new(source);
47 let theme = ThemeSet::load_from_reader(&mut reader)
48 .map_err(|source| CodeThemeLoadError { path: None, source })?;
49 Ok(Self { theme })
50 }
51
52 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, CodeThemeLoadError> {
73 let path = path.as_ref();
74 let theme = ThemeSet::get_theme(path).map_err(|source| CodeThemeLoadError {
75 path: Some(path.to_owned()),
76 source,
77 })?;
78 Ok(Self { theme })
79 }
80}
81
82#[non_exhaustive]
87#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
88pub enum BuiltinCodeTheme {
89 Base16EightiesDark,
91 Base16MochaDark,
93 #[default]
95 Base16OceanDark,
96 Base16OceanLight,
98 InspiredGitHub,
100 SolarizedDark,
102 SolarizedLight,
104}
105
106impl BuiltinCodeTheme {
107 fn syntect_name(self) -> &'static str {
108 match self {
109 Self::Base16EightiesDark => "base16-eighties.dark",
110 Self::Base16MochaDark => "base16-mocha.dark",
111 Self::Base16OceanDark => "base16-ocean.dark",
112 Self::Base16OceanLight => "base16-ocean.light",
113 Self::InspiredGitHub => "InspiredGitHub",
114 Self::SolarizedDark => "Solarized (dark)",
115 Self::SolarizedLight => "Solarized (light)",
116 }
117 }
118}
119
120impl From<BuiltinCodeTheme> for CodeTheme {
121 fn from(theme: BuiltinCodeTheme) -> Self {
122 let theme = builtin_theme(theme).clone();
123 Self { theme }
124 }
125}
126
127#[non_exhaustive]
133#[derive(Debug)]
134pub struct CodeThemeLoadError {
135 path: Option<PathBuf>,
136 source: syntect::LoadingError,
137}
138
139impl fmt::Display for CodeThemeLoadError {
140 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
141 if let Some(path) = &self.path {
142 write!(
143 formatter,
144 "failed to load code theme from `{}`: {}",
145 path.display(),
146 self.source
147 )
148 } else {
149 write!(
150 formatter,
151 "failed to parse TextMate code theme: {}",
152 self.source
153 )
154 }
155 }
156}
157
158impl Error for CodeThemeLoadError {
159 fn source(&self) -> Option<&(dyn Error + 'static)> {
160 Some(&self.source)
161 }
162}
163
164pub fn theme(code_theme: &CodeTheme) -> &Theme {
166 &code_theme.theme
167}
168
169pub fn default() -> &'static CodeTheme {
174 &DEFAULT_THEME
175}
176
177fn builtin_theme(code_theme: BuiltinCodeTheme) -> &'static Theme {
178 THEMES
179 .themes
180 .get(code_theme.syntect_name())
181 .expect("every BuiltinCodeTheme variant must map to a bundled theme")
182}
183
184static DEFAULT_THEME: LazyLock<CodeTheme> = LazyLock::new(|| BuiltinCodeTheme::default().into());
185static THEMES: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
186
187#[cfg(test)]
188mod tests {
189 use std::path::PathBuf;
190
191 use indoc::indoc;
192 use ratatui_core::style::Color;
193
194 use crate::{from_str_with_options, Options};
195
196 use super::*;
197
198 fn fixture(name: &str) -> PathBuf {
199 Path::new(env!("CARGO_MANIFEST_DIR"))
200 .join("src/code_theme/fixtures")
201 .join(name)
202 }
203
204 #[test]
205 fn every_builtin_theme_can_be_selected() {
206 let themes = [
207 BuiltinCodeTheme::Base16EightiesDark,
208 BuiltinCodeTheme::Base16MochaDark,
209 BuiltinCodeTheme::Base16OceanDark,
210 BuiltinCodeTheme::Base16OceanLight,
211 BuiltinCodeTheme::InspiredGitHub,
212 BuiltinCodeTheme::SolarizedDark,
213 BuiltinCodeTheme::SolarizedLight,
214 ];
215
216 for built_in in themes {
217 let code_theme = CodeTheme::from(built_in);
218 let _ = theme(&code_theme);
219 }
220 }
221
222 #[test]
223 fn configured_theme_is_borrowed_directly() {
224 let code_theme = CodeTheme::from(BuiltinCodeTheme::SolarizedDark);
225
226 assert!(std::ptr::eq(theme(&code_theme), &code_theme.theme));
227 }
228
229 #[test]
230 fn default_theme_is_shared() {
231 assert!(std::ptr::eq(default(), default()));
232 }
233
234 #[test]
235 fn loaded_theme_applies_its_foreground_color() {
236 let theme = CodeTheme::from_file(fixture("custom.tmTheme")).unwrap();
237
238 assert_eq!(
239 rendered_keyword_foreground(theme),
240 Some(Color::Rgb(255, 255, 255))
241 );
242 }
243
244 #[test]
245 fn embedded_theme_applies_its_foreground_color() {
246 let source = include_str!("code_theme/fixtures/custom.tmTheme");
247 let theme = CodeTheme::from_textmate(source).unwrap();
248
249 assert_eq!(
250 rendered_keyword_foreground(theme),
251 Some(Color::Rgb(255, 255, 255))
252 );
253 }
254
255 fn rendered_keyword_foreground(theme: CodeTheme) -> Option<Color> {
256 let input = indoc! {"
257 ```rust
258 fn main() {}
259 ```
260 "};
261 let options = Options::default().code_theme(theme);
262 let rendered = from_str_with_options(input, &options);
263 rendered
264 .lines
265 .iter()
266 .flat_map(|line| &line.spans)
267 .find(|span| span.content == "fn")
268 .expect("Rust highlighting should emit the `fn` keyword")
269 .style
270 .fg
271 }
272
273 #[test]
274 fn missing_theme_reports_its_path_and_read_error() {
275 let path = fixture("missing.tmTheme");
276 let error = CodeTheme::from_file(&path).unwrap_err();
277
278 let prefix = format!("failed to load code theme from `{}`:", path.display());
279 assert!(error.to_string().starts_with(&prefix));
280 assert!(matches!(&error.source, syntect::LoadingError::Io(_)));
281 assert!(error.source().is_some());
282 }
283
284 #[test]
285 fn malformed_theme_reports_its_path_and_parse_error() {
286 let path = fixture("invalid.tmTheme");
287 let error = CodeTheme::from_file(&path).unwrap_err();
288
289 let prefix = format!("failed to load code theme from `{}`:", path.display());
290 assert!(error.to_string().starts_with(&prefix));
291 assert!(matches!(
292 &error.source,
293 syntect::LoadingError::ReadSettings(_)
294 ));
295 assert!(error.source().is_some());
296 }
297
298 #[test]
299 fn malformed_embedded_theme_reports_a_parse_error() {
300 let error = CodeTheme::from_textmate("this is not a TextMate theme").unwrap_err();
301
302 assert!(error
303 .to_string()
304 .starts_with("failed to parse TextMate code theme:"));
305 assert!(matches!(
306 &error.source,
307 syntect::LoadingError::ReadSettings(_)
308 ));
309 assert!(error.source().is_some());
310 }
311}