1use std::path::{Path, PathBuf};
6
7use crate::config::{ColorConfig, ColorParser, ThemeMode};
8
9pub const FOLDER: &str = "themes";
11pub const NIGHT_MARKET: &str = "night-market";
13pub const DAY_MARKET: &str = "day-market";
15
16const EXTENDS: &str = "extends";
18const DESCRIPTION: &str = "description";
19const MAX_DEPTH: usize = 16;
21
22pub fn built_in_name(mode: ThemeMode) -> &'static str {
24 match mode {
25 ThemeMode::Light => DAY_MARKET,
26 _ => NIGHT_MARKET,
27 }
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub struct ThemeFile {
33 pub name: String,
35 pub path: PathBuf,
36 pub extends: Option<String>,
37 pub description: Option<String>,
38 pub colors: toml::Table,
40}
41
42#[derive(Debug, Clone, PartialEq)]
44pub struct Broken {
45 pub name: String,
46 pub path: PathBuf,
47 pub message: String,
48}
49
50impl Broken {
51 pub fn full(&self) -> String {
53 format!("{}: {}", self.path.display(), self.message)
54 }
55}
56
57#[derive(Debug, Clone, Default, PartialEq)]
59pub struct Library {
60 pub files: Vec<ThemeFile>,
62 pub broken: Vec<Broken>,
63}
64
65#[derive(Debug, Clone, PartialEq)]
67pub enum Source<'a> {
68 BuiltIn(ThemeMode),
69 File(&'a ThemeFile),
70}
71
72impl Library {
73 pub fn read(config_dir: Option<&Path>) -> Self {
77 let mut library = Self::default();
78 let Some(dir) = config_dir else {
79 return library;
80 };
81 let mut paths: Vec<PathBuf> = match std::fs::read_dir(dir.join(FOLDER)) {
82 Ok(entries) => entries
83 .filter_map(|e| e.ok().map(|e| e.path()))
84 .filter(|p| {
85 p.extension().is_some_and(|x| x == "toml")
86 && std::fs::metadata(p).is_ok_and(|m| m.is_file())
87 })
88 .collect(),
89 Err(_) => Vec::new(),
90 };
91 paths.sort();
92 for path in paths {
93 let name = path
94 .file_stem()
95 .map(|s| s.to_string_lossy().into_owned())
96 .unwrap_or_default();
97 let read = std::fs::read_to_string(&path)
98 .map_err(|e| e.to_string())
99 .and_then(|text| parse(&name, &path, &text));
100 match read {
101 Ok(file) => library.files.push(file),
102 Err(message) => library.broken.push(Broken {
103 name,
104 path,
105 message,
106 }),
107 }
108 }
109 library
110 }
111
112 pub fn find(&self, name: &str) -> Option<Source<'_>> {
114 match name {
115 NIGHT_MARKET => Some(Source::BuiltIn(ThemeMode::Dark)),
116 DAY_MARKET => Some(Source::BuiltIn(ThemeMode::Light)),
117 _ => self.files.iter().find(|f| f.name == name).map(Source::File),
118 }
119 }
120
121 pub fn names(&self) -> Vec<&str> {
123 let mut names = vec![NIGHT_MARKET, DAY_MARKET];
124 names.extend(self.files.iter().map(|f| f.name.as_str()));
125 names
126 }
127
128 pub fn resolve(&self, name: &str, mode: ThemeMode) -> Result<ColorConfig, String> {
131 let mut chain: Vec<&ThemeFile> = Vec::new();
132 let mut at = name;
133 let base = loop {
134 match self.find(at) {
135 Some(Source::BuiltIn(built)) => break ColorConfig::for_mode(built),
136 Some(Source::File(file)) => {
137 if let Some(seen) = chain.iter().position(|f| f.name == file.name) {
138 let names: Vec<&str> = chain[seen..]
139 .iter()
140 .map(|f| f.name.as_str())
141 .chain([file.name.as_str()])
142 .collect();
143 return Err(format!("themes extend in a circle: {}", names.join(" > ")));
144 }
145 if chain.len() >= MAX_DEPTH {
146 return Err(format!(
147 "theme {name} extends more than {MAX_DEPTH} themes deep"
148 ));
149 }
150 chain.push(file);
151 match &file.extends {
152 Some(next) => at = next,
153 None => break ColorConfig::for_mode(mode),
154 }
155 }
156 None => return Err(self.missing(at, chain.last().copied())),
157 }
158 };
159 let mut palette = slots(&base);
160 for file in chain.iter().rev() {
161 palette.extend(file.colors.clone());
162 }
163 toml::Value::Table(palette)
164 .try_into()
165 .map_err(|e: toml::de::Error| e.message().to_string())
166 }
167
168 fn missing(&self, name: &str, by: Option<&ThemeFile>) -> String {
170 let from = by.map_or_else(String::new, |f| {
171 format!(" ({} extends it)", f.path.display())
172 });
173 if let Some(broken) = self.broken.iter().find(|b| b.name == name) {
174 return format!(
175 "theme {name} was left out for a mistake{from}: {}",
176 broken.full()
177 );
178 }
179 format!(
180 "no theme is named {name}{from}. Themes: {}",
181 self.names().join(", ")
182 )
183 }
184}
185
186pub fn slots(colors: &ColorConfig) -> toml::Table {
188 match toml::Value::try_from(colors) {
189 Ok(toml::Value::Table(table)) => table,
190 _ => unreachable!("a struct serializes to a table"),
191 }
192}
193
194pub fn parse(name: &str, path: &Path, text: &str) -> Result<ThemeFile, String> {
197 if name == NIGHT_MARKET || name == DAY_MARKET {
198 return Err(format!(
199 "{name} is a built-in theme's name; rename the file and set extends = \"{name}\""
200 ));
201 }
202 let table: toml::Table = text.parse().map_err(|e: toml::de::Error| match e.span() {
203 Some(span) => format!(
204 "line {}: {}",
205 text[..span.start].matches('\n').count() + 1,
206 e.message()
207 ),
208 None => e.message().to_string(),
209 })?;
210 let known = slots(&ColorConfig::dark());
211 let parser = ColorParser::new();
212 let mut file = ThemeFile {
213 name: name.to_string(),
214 path: path.to_path_buf(),
215 extends: None,
216 description: None,
217 colors: toml::Table::new(),
218 };
219 for (key, value) in table {
220 let text = |value: toml::Value| match value {
221 toml::Value::String(s) => Ok(s),
222 other => Err(format!("{key} must be a string, got {other}")),
223 };
224 match key.as_str() {
225 EXTENDS => file.extends = Some(text(value)?),
226 DESCRIPTION => file.description = Some(text(value)?),
227 _ if known.contains_key(&key) => {
228 let color = text(value)?;
229 if !(key == "table_alternate_row" && color == "default") {
231 parser.parse(&color).map_err(|e| format!("{key}: {e}"))?;
232 }
233 file.colors.insert(key, toml::Value::String(color));
234 }
235 "theme" | "colors" => {
236 return Err(format!(
237 "a theme file holds the slots at its top level; drop the [{key}...] header"
238 ));
239 }
240 _ => {
241 let near: Vec<&str> =
242 datui_cli::settings::suggestions(&format!("theme.colors.{key}"))
243 .into_iter()
244 .filter_map(|k| k.strip_prefix("theme.colors."))
245 .collect();
246 let mut message = format!("{key} is not a color slot");
247 if !near.is_empty() {
248 message.push_str(&format!("; did you mean {}?", near.join(" or ")));
249 }
250 return Err(message);
251 }
252 }
253 }
254 if file.extends.as_deref() == Some(name) {
255 return Err(format!("{name} extends itself"));
256 }
257 Ok(file)
258}
259
260fn built_in_description(mode: ThemeMode) -> &'static str {
262 match mode {
263 ThemeMode::Light => "Tokyo Night's day variant, for a light terminal",
264 _ => "Tokyo Night with one cyan accent, for a dark terminal",
265 }
266}
267
268pub fn command(
271 action: &datui_cli::ThemeAction,
272 config: color_eyre::Result<crate::config::AppConfig>,
273) -> (String, i32) {
274 use datui_cli::ThemeAction;
275 use datui_cli::exit::{FAILURE, SUCCESS};
276 let config = match config {
277 Ok(config) => config,
278 Err(e) => return (format!("{e}\n"), FAILURE),
279 };
280 let theme = &config.theme;
281 let library = &theme.library;
282 match action {
283 ThemeAction::List => {
284 let set_for = |name: &str| {
285 let modes: Vec<&str> = [
286 (theme.dark_theme.as_str(), "dark"),
287 (theme.light_theme.as_str(), "light"),
288 ]
289 .into_iter()
290 .filter(|(used, _)| *used == name)
291 .map(|(_, mode)| mode)
292 .collect();
293 if modes.is_empty() {
294 "-".to_string()
295 } else {
296 modes.join(", ")
297 }
298 };
299 let mut rows = vec![vec![
300 "NAME".to_string(),
301 "SET FOR".to_string(),
302 "FROM".to_string(),
303 "DESCRIPTION".to_string(),
304 ]];
305 for mode in [ThemeMode::Dark, ThemeMode::Light] {
306 let name = built_in_name(mode);
307 rows.push(vec![
308 name.to_string(),
309 set_for(name),
310 "built in".to_string(),
311 built_in_description(mode).to_string(),
312 ]);
313 }
314 for file in &library.files {
315 rows.push(vec![
316 file.name.clone(),
317 set_for(&file.name),
318 file.path.display().to_string(),
319 file.description.clone().unwrap_or_default(),
320 ]);
321 }
322 for broken in &library.broken {
323 rows.push(vec![
324 broken.name.clone(),
325 "-".to_string(),
326 broken.path.display().to_string(),
327 format!(
328 "{} not read: {}",
329 crate::glyphs::get().warning,
330 broken.message
331 ),
332 ]);
333 }
334 (crate::catalog::table(&rows), SUCCESS)
335 }
336 ThemeAction::Show { name } => {
337 let (mode, description) = match library.find(name) {
338 Some(Source::BuiltIn(mode)) => (mode, None),
340 Some(Source::File(file)) => (
343 if theme.light_theme == *name && theme.dark_theme != *name {
344 ThemeMode::Light
345 } else {
346 ThemeMode::Dark
347 },
348 file.description.clone(),
349 ),
350 None => return (format!("{}\n", library.missing(name, None)), FAILURE),
351 };
352 match library.resolve(name, mode) {
353 Ok(colors) => (show(name, description.as_deref(), &colors), SUCCESS),
354 Err(e) => (format!("{e}\n"), FAILURE),
355 }
356 }
357 }
358}
359
360pub fn show(name: &str, description: Option<&str>, colors: &ColorConfig) -> String {
363 let palette = slots(colors);
364 let mut out = format!(
365 "# Every slot of the {name} theme. Saved as themes/NAME.toml in the config\n\
366 # directory, it is a theme named NAME to edit. A file with extends = \"{name}\"\n\
367 # and only the slots it changes works too.\n"
368 );
369 if let Some(description) = description {
370 out.push_str(&format!(
371 "description = {}\n",
372 toml::Value::String(description.to_string())
373 ));
374 }
375 for setting in datui_cli::settings::in_section("theme.colors") {
376 let slot = setting.name();
377 let Some(value) = palette.get(slot) else {
378 continue;
379 };
380 out.push_str(&format!("\n# {}\n{slot} = {value}\n", setting.doc));
381 }
382 out
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 fn library(files: &[(&str, &str)]) -> Library {
390 let mut library = Library::default();
391 for (name, text) in files {
392 let path = PathBuf::from(format!("{name}.toml"));
393 match parse(name, &path, text) {
394 Ok(file) => library.files.push(file),
395 Err(message) => library.broken.push(Broken {
396 name: name.to_string(),
397 path,
398 message,
399 }),
400 }
401 }
402 library
403 }
404
405 #[test]
406 fn built_ins_are_today_s_palettes() {
407 let none = Library::default();
408 for mode in [ThemeMode::Dark, ThemeMode::Light] {
409 assert_eq!(none.resolve(NIGHT_MARKET, mode), Ok(ColorConfig::dark()));
410 assert_eq!(none.resolve(DAY_MARKET, mode), Ok(ColorConfig::light()));
411 }
412 }
413
414 #[test]
417 fn ten_series_slots_everywhere() {
418 let lib = library(&[("dusk", "extends = \"day-market\"\nchart_9 = \"red\"\n")]);
419 let dusk = lib.resolve("dusk", ThemeMode::Dark).unwrap();
420 assert_eq!(dusk.chart_8, ColorConfig::light().chart_8);
421 assert_eq!(dusk.chart_9, "red");
422 for (name, colors) in [
423 (NIGHT_MARKET, ColorConfig::dark()),
424 (DAY_MARKET, ColorConfig::light()),
425 ] {
426 let shown = show(name, None, &colors);
427 for slot in ["chart_8", "chart_9", "chart_10"] {
428 assert!(shown.contains(&format!("\n{slot} = \"#")), "{shown}");
429 }
430 }
431 let init = crate::config::ConfigManager::with_dir(PathBuf::from("unused"))
432 .generate_default_config();
433 assert!(init.contains("chart_10 = \"#f4ef8a\""), "{init}");
434 }
435
436 #[test]
437 fn a_theme_fills_unset_slots_from_extends_or_the_mode() {
438 let lib = library(&[
439 ("dusk", "extends = \"day-market\"\naccent = \"#e0af68\"\n"),
440 ("bare", "accent = \"#e0af68\"\n"),
441 ("deeper", "extends = \"dusk\"\nchip_key = \"red\"\n"),
442 ]);
443 let dusk = lib.resolve("dusk", ThemeMode::Dark).unwrap();
444 assert_eq!(dusk.accent, "#e0af68");
445 assert_eq!(dusk.controls_bg, ColorConfig::light().controls_bg);
446
447 for (mode, stock) in [
448 (ThemeMode::Dark, ColorConfig::dark()),
449 (ThemeMode::Light, ColorConfig::light()),
450 ] {
451 let bare = lib.resolve("bare", mode).unwrap();
452 assert_eq!(bare.accent, "#e0af68");
453 assert_eq!(bare.controls_bg, stock.controls_bg, "{mode:?}");
454 }
455
456 let deeper = lib.resolve("deeper", ThemeMode::Dark).unwrap();
457 assert_eq!(deeper.chip_key, "red");
458 assert_eq!(deeper.accent, "#e0af68");
459 assert_eq!(deeper.controls_bg, ColorConfig::light().controls_bg);
460 }
461
462 #[test]
463 fn a_circle_or_an_unknown_name_is_an_error_naming_it() {
464 let lib = library(&[
465 ("a", "extends = \"b\"\n"),
466 ("b", "extends = \"a\"\n"),
467 ("c", "extends = \"nope\"\n"),
468 ]);
469 let e = lib.resolve("a", ThemeMode::Dark).unwrap_err();
470 assert!(e.contains("a > b > a"), "{e}");
471 let e = lib.resolve("c", ThemeMode::Dark).unwrap_err();
472 assert!(e.contains("nope") && e.contains("c.toml extends it"), "{e}");
473 let e = lib.resolve("zzz", ThemeMode::Dark).unwrap_err();
474 assert!(e.contains("no theme is named zzz"), "{e}");
475 }
476
477 #[test]
478 fn a_file_with_a_mistake_is_left_out_with_why() {
479 let lib = library(&[
480 ("bad-color", "accent = \"#zz\"\n"),
481 ("bad-key", "acent = \"red\"\n"),
482 ("bad-toml", "accent = \n"),
483 ("self", "extends = \"self\"\n"),
484 ("night-market", "accent = \"red\"\n"),
485 ("good", "extends = \"bad-color\"\n"),
486 ("header", "[theme.colors]\naccent = \"red\"\n"),
487 ]);
488 let why = |name: &str| {
489 lib.broken
490 .iter()
491 .find(|b| b.name == name)
492 .map(|b| b.message.clone())
493 .unwrap_or_else(|| panic!("{name} not broken"))
494 };
495 assert!(why("bad-color").starts_with("accent:"));
496 assert!(
497 why("bad-key").contains("did you mean accent"),
498 "{}",
499 why("bad-key")
500 );
501 assert!(why("bad-toml").starts_with("line 1"), "{}", why("bad-toml"));
502 assert!(why("self").contains("extends itself"));
503 assert!(why("header").contains("top level"), "{}", why("header"));
504 assert!(why("night-market").contains("built-in"));
505 let e = lib.resolve("good", ThemeMode::Dark).unwrap_err();
506 assert!(e.contains("left out for a mistake"), "{e}");
507 }
508
509 #[test]
510 fn show_parses_back_to_the_same_theme() {
511 let lib = library(&[(
512 "dusk",
513 "extends = \"night-market\"\ndescription = \"Dusk\"\naccent = \"#e0af68\"\n",
514 )]);
515 let colors = lib.resolve("dusk", ThemeMode::Dark).unwrap();
516 let text = show("dusk", Some("Dusk"), &colors);
517 let back = parse("copy", Path::new("copy.toml"), &text).unwrap();
518 assert_eq!(back.extends, None);
519 assert_eq!(back.description.as_deref(), Some("Dusk"));
520 assert_eq!(back.colors, slots(&colors), "every slot");
521 let again = library(&[("copy", &text)]);
522 assert_eq!(again.resolve("copy", ThemeMode::Light), Ok(colors));
523 }
524}