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::home::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;