Skip to main content

max / makeover

21.4 KB · 568 lines History Blame Raw
1 //! Loading / parsing
2
3 use crate::{
4 COLOR_SECTIONS, Emphasis, Rgb, STEP_FLOOR, SemanticTokens, ThemeColors, ThemeMeta,
5 find_theme_path, resolve, tonal, wcag_contrast,
6 };
7 use serde::Serialize;
8 use std::collections::HashMap;
9 use std::path::{Path, PathBuf};
10
11 // Names this module's prose links to, resolved for rustdoc.
12 #[allow(unused_imports)]
13 use crate::ansi_intent;
14
15 /// Validate a theme ID contains only safe characters (alphanumeric, hyphens, underscores).
16 pub fn validate_theme_id(id: &str) -> Result<(), String> {
17 if !id
18 .chars()
19 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
20 {
21 return Err(format!("Invalid theme ID: {id}"));
22 }
23 Ok(())
24 }
25
26 /// Parse the `[meta]` section into `ThemeMeta`.
27 ///
28 /// Falls back to the file ID as the name and `"dark"` as the variant.
29 pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
30 let meta = table.get("meta").and_then(|m| m.as_table());
31 let name = meta
32 .and_then(|m| m.get("name"))
33 .and_then(|v| v.as_str())
34 .unwrap_or(id)
35 .to_string();
36 let variant = meta
37 .and_then(|m| m.get("variant"))
38 .and_then(|v| v.as_str())
39 .unwrap_or("dark")
40 .to_string();
41
42 ThemeMeta {
43 id: id.to_string(),
44 name,
45 variant,
46 is_custom,
47 }
48 }
49
50 /// Extract the intent color sections into a flat `HashMap` with dotted keys
51 /// like `"surface.page"`, `"status.danger"`, `"category.one"`.
52 ///
53 /// The tonal steps of `content.primary` are filled in here rather than read, by
54 /// [`derive_tonal_steps`]. Anything a theme authored under those keys is
55 /// replaced.
56 pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
57 let mut colors = HashMap::new();
58 for section in COLOR_SECTIONS {
59 if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
60 for (key, val) in sect {
61 if let Some(color) = val.as_str() {
62 colors.insert(format!("{section}.{key}"), color.to_string());
63 }
64 }
65 }
66 }
67 derive_tonal_steps(&mut colors);
68 colors
69 }
70
71 /// Fill in the tonal steps of `content.primary`, overwriting whatever the theme
72 /// authored under those keys.
73 ///
74 /// # Why they are not authored
75 ///
76 /// `content.secondary` and `content.muted` are not independent colours. They are
77 /// the ink, one step and two steps back, and a theme that names them separately
78 /// is stating three times something it stated once — which is how three of the
79 /// bundled themes came to author a `secondary` *lighter* than their own
80 /// `primary` (nord, solarized-dark) or identical to it (dracula), inverting the
81 /// emphasis ramp the whole vocabulary rests on. Deriving them makes
82 /// `content` > `content-secondary` > `content-muted` true by construction in
83 /// every theme, including one a user writes.
84 ///
85 /// Applied at load rather than in [`resolve`] so that there is one answer: the
86 /// resolved token layer, the ANSI table ([`ansi_intent`] reads authored keys),
87 /// and every consumer holding a [`ThemeColors`] all see the same value. A
88 /// derivation visible from only one of those is how a terminal and a webview
89 /// come to disagree about what muted means.
90 ///
91 /// Both keys need `content.primary` and `surface.page` to exist and parse. When
92 /// either is missing the step is skipped and anything authored is left where it
93 /// is, mirroring the skip-missing behaviour of the rest of the crate — a
94 /// half-written theme keeps whatever it has rather than losing it.
95 ///
96 /// # The ratio is a starting point, not the answer
97 ///
98 /// Each step is pushed further toward the page until it clears [`STEP_FLOOR`]
99 /// against the ink, so what the theme gets is a step that can be seen rather
100 /// than a step of the agreed size. The two are the same number in every bundled
101 /// theme but the two with a pure-black ink, where the ratio has no range to
102 /// travel in and the nominal step lands 3/255 from where it started.
103 pub fn derive_tonal_steps<S: std::hash::BuildHasher>(colors: &mut HashMap<String, String, S>) {
104 let ink = colors.get("content.primary").and_then(|v| Rgb::from_hex(v));
105 let page = colors.get("surface.page").and_then(|v| Rgb::from_hex(v));
106 let (Some(ink), Some(page)) = (ink, page) else {
107 return;
108 };
109 // Each step starts no nearer than the one before it landed, so pushing
110 // secondary out cannot carry it past muted and invert the ramp.
111 let mut reached = 0.0;
112 for (key, step) in [
113 ("content.secondary", Emphasis::Secondary),
114 ("content.muted", Emphasis::Muted),
115 ] {
116 let (color, ratio) = step_clearing_floor(ink, page, step.ratio().max(reached));
117 reached = ratio;
118 colors.insert(key.to_string(), color.to_hex());
119 }
120 }
121
122 /// The step `from` of the way from `ink` to `page`, pushed toward `page` until
123 /// it clears [`STEP_FLOOR`] against the ink it is a step of. Returns the colour
124 /// and the ratio it was found at.
125 ///
126 /// A forward scan rather than a solve, because it wants the *first* ratio that
127 /// clears: contrast against the base rises with the distance travelled, but it
128 /// rises through sRGB's transfer curve and OKLab's chroma path, and a bisection
129 /// would trust a monotonicity nothing here guarantees.
130 ///
131 /// Travel stops at the ground. A theme whose ink and page are the same colour
132 /// has no step to take, and the ground is the honest answer — nothing past it
133 /// is a step of the ink any more.
134 fn step_clearing_floor(ink: Rgb, page: Rgb, from: f32) -> (Rgb, f32) {
135 // Finer than 8-bit sRGB can resolve on the shortest ramp in the corpus, so
136 // the scan never steps over the first colour that clears.
137 const PROBE: f32 = 0.005;
138 let mut ratio = from.clamp(0.0, 1.0);
139 loop {
140 let color = tonal(ink, page, ratio);
141 if wcag_contrast(color, ink) >= STEP_FLOOR || ratio >= 1.0 {
142 return (color, ratio);
143 }
144 ratio = (ratio + PROBE).min(1.0);
145 }
146 }
147
148 /// Scan directories for `.toml` theme files and return metadata for each.
149 ///
150 /// Directories are checked in order; later entries override earlier ones by ID.
151 /// Each entry in `dirs` is `(path, is_custom)`.
152 pub fn list_themes_from_dirs(dirs: &[(PathBuf, bool)]) -> Vec<ThemeMeta> {
153 let mut seen: HashMap<String, ThemeMeta> = HashMap::new();
154
155 for (dir, is_custom) in dirs {
156 let Ok(entries) = std::fs::read_dir(dir) else {
157 continue;
158 };
159
160 for entry in entries {
161 let Ok(entry) = entry else {
162 continue;
163 };
164 let path = entry.path();
165 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
166 continue;
167 }
168
169 let id = path
170 .file_stem()
171 .and_then(|s| s.to_str())
172 .unwrap_or_default()
173 .to_string();
174
175 let Ok(content) = std::fs::read_to_string(&path) else {
176 continue;
177 };
178 let table: toml::Table = match content.parse() {
179 Ok(t) => t,
180 Err(_) => continue,
181 };
182
183 seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
184 }
185 }
186
187 let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
188 themes.sort_by(|a, b| a.name.cmp(&b.name));
189 themes
190 }
191
192 /// Parse a complete theme (metadata + colors) from raw TOML content, with no
193 /// filesystem access. For callers that embed themes at compile time.
194 pub fn parse_theme_str(id: &str, content: &str, is_custom: bool) -> Result<ThemeColors, String> {
195 validate_theme_id(id)?;
196 let table: toml::Table = content
197 .parse()
198 .map_err(|e| format!("Failed to parse theme '{id}': {e}"))?;
199 let meta = parse_meta(id, &table, is_custom);
200 let colors = extract_colors(&table);
201 Ok(ThemeColors { meta, colors })
202 }
203
204 /// Load a complete theme (metadata + colors) by ID from the given directories.
205 pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemeColors, String> {
206 validate_theme_id(id)?;
207
208 let (path, is_custom) =
209 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
210
211 let content = std::fs::read_to_string(&path)
212 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
213
214 let table: toml::Table = content
215 .parse()
216 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
217
218 let meta = parse_meta(id, &table, is_custom);
219 let colors = extract_colors(&table);
220
221 Ok(ThemeColors { meta, colors })
222 }
223
224 /// Load a theme and resolve it to the full intent token set in one step.
225 pub fn load_semantic(dirs: &[(PathBuf, bool)], id: &str) -> Result<SemanticTokens, String> {
226 Ok(resolve(&load_theme(dirs, id)?))
227 }
228
229 /// Import a theme TOML file into the custom themes directory.
230 ///
231 /// Validates that the file is parseable TOML with at least one intent color
232 /// section, then copies it to `custom_dir/{id}.toml`. Returns the theme metadata.
233 pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, String> {
234 let content = std::fs::read_to_string(source_path)
235 .map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?;
236
237 let table: toml::Table = content.parse().map_err(|e| format!("Invalid TOML: {e}"))?;
238
239 let has_colors = COLOR_SECTIONS
240 .iter()
241 .any(|s| table.get(*s).and_then(|v| v.as_table()).is_some());
242 if !has_colors {
243 return Err(format!(
244 "Theme file must have at least one color section ({})",
245 COLOR_SECTIONS.join(", ")
246 ));
247 }
248
249 let id = source_path
250 .file_stem()
251 .and_then(|s| s.to_str())
252 .ok_or("Invalid file name")?
253 .to_string();
254 validate_theme_id(&id)?;
255
256 std::fs::create_dir_all(custom_dir)
257 .map_err(|e| format!("Failed to create {}: {}", custom_dir.display(), e))?;
258
259 let dest = custom_dir.join(format!("{id}.toml"));
260 std::fs::copy(source_path, &dest).map_err(|e| format!("Failed to copy theme: {e}"))?;
261
262 Ok(parse_meta(&id, &table, true))
263 }
264
265 /// Delete a custom theme by ID.
266 ///
267 /// Only operates on `custom_dir` — bundled themes are not deletable through
268 /// this entry point.
269 pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
270 validate_theme_id(id)?;
271
272 let path = custom_dir.join(format!("{id}.toml"));
273 if !path.is_file() {
274 return Err(format!("Custom theme '{id}' not found"));
275 }
276
277 std::fs::remove_file(&path).map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
278 }
279
280 /// A four-color preview for theme thumbnails: the representative swatch from
281 /// each of the principal roles.
282 #[derive(Debug, Clone, Serialize)]
283 #[serde(rename_all = "camelCase")]
284 pub struct ThemePreview {
285 pub meta: ThemeMeta,
286 /// Page background (`surface.page`).
287 pub background: Option<String>,
288 /// Body text (`content.primary`).
289 pub foreground: Option<String>,
290 /// Brand/interactive color (`action.primary`).
291 pub accent: Option<String>,
292 /// Divider/outline color (`line.border`).
293 pub border: Option<String>,
294 }
295
296 fn color_at(table: &toml::Table, section: &str, key: &str) -> Option<String> {
297 table
298 .get(section)
299 .and_then(|s| s.as_table())
300 .and_then(|s| s.get(key))
301 .and_then(|v| v.as_str())
302 .map(std::string::ToString::to_string)
303 }
304
305 /// Load just the preview swatches for a theme — for UI thumbnails.
306 pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePreview, String> {
307 validate_theme_id(id)?;
308
309 let (path, is_custom) =
310 find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
311
312 let content = std::fs::read_to_string(&path)
313 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
314
315 let table: toml::Table = content
316 .parse()
317 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
318
319 Ok(ThemePreview {
320 meta: parse_meta(id, &table, is_custom),
321 background: color_at(&table, "surface", "page"),
322 foreground: color_at(&table, "content", "primary"),
323 accent: color_at(&table, "action", "primary"),
324 border: color_at(&table, "line", "border"),
325 })
326 }
327
328 /// Export a theme to a user-chosen path.
329 pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> {
330 validate_theme_id(id)?;
331
332 let (source, _) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
333
334 std::fs::copy(&source, dest_path).map_err(|e| format!("Failed to export theme: {e}"))?;
335
336 Ok(())
337 }
338
339 #[cfg(test)]
340 mod tests {
341 use super::*;
342 use crate::fixture::nord_toml;
343 use crate::{bundled_themes_dir, embedded_themes};
344 use std::fs;
345
346 // ---- id validation ----
347
348 #[test]
349 fn validate_theme_id_alphanumeric() {
350 assert!(validate_theme_id("darkmode").is_ok());
351 assert!(validate_theme_id("Theme123").is_ok());
352 }
353
354 #[test]
355 fn validate_theme_id_hyphens_underscores() {
356 assert!(validate_theme_id("dark-mode").is_ok());
357 assert!(validate_theme_id("my_theme_v2").is_ok());
358 }
359
360 #[test]
361 fn validate_theme_id_rejects_path_traversal() {
362 assert!(validate_theme_id("../etc/passwd").is_err());
363 assert!(validate_theme_id("foo/bar").is_err());
364 assert!(validate_theme_id("theme.toml").is_err());
365 }
366
367 // ---- meta ----
368
369 #[test]
370 fn parse_meta_with_name_and_variant() {
371 let table: toml::Table = "[meta]\nname = \"Nord\"\nvariant = \"light\"\n"
372 .parse()
373 .unwrap();
374 let meta = parse_meta("nord", &table, false);
375 assert_eq!(meta.id, "nord");
376 assert_eq!(meta.name, "Nord");
377 assert_eq!(meta.variant, "light");
378 assert!(!meta.is_custom);
379 }
380
381 #[test]
382 fn parse_meta_defaults_to_id_and_dark() {
383 let table: toml::Table = "".parse().unwrap();
384 let meta = parse_meta("fallback", &table, true);
385 assert_eq!(meta.name, "fallback");
386 assert_eq!(meta.variant, "dark");
387 assert!(meta.is_custom);
388 }
389
390 #[test]
391 fn extract_colors_reads_intent_sections() {
392 let table: toml::Table = nord_toml().parse().unwrap();
393 let colors = extract_colors(&table);
394 assert_eq!(colors.get("surface.page").unwrap(), "#2e3440");
395 assert_eq!(colors.get("content.primary").unwrap(), "#d8dee9");
396 assert_eq!(colors.get("action.primary").unwrap(), "#81a1c1");
397 assert_eq!(colors.get("status.danger").unwrap(), "#bf616a");
398 assert_eq!(colors.get("line.border").unwrap(), "#4c566a");
399 assert_eq!(colors.get("category.five").unwrap(), "#b48ead");
400 assert_eq!(colors.len(), 19);
401 }
402
403 #[test]
404 fn every_shipped_theme_ramps_one_way() {
405 // The property authoring the steps separately could not hold: three
406 // themes had shipped a secondary lighter than their own primary, so a
407 // renderer reading the emphasis order got the reverse of it.
408 for (id, toml) in embedded_themes() {
409 let theme = parse_theme_str(id, toml, false).unwrap();
410 let t = resolve(&theme);
411 let page = Rgb::from_hex(t.hex("surface-page").unwrap()).unwrap();
412 let steps = ["content", "content-secondary", "content-muted"]
413 .map(|k| wcag_contrast(Rgb::from_hex(t.hex(k).unwrap()).unwrap(), page));
414 assert!(
415 steps[0] > steps[1] && steps[1] > steps[2],
416 "{id}: emphasis does not fall monotonically: {steps:?}"
417 );
418 }
419 }
420
421 #[test]
422 fn every_shipped_theme_takes_a_visible_first_step() {
423 // The property that was missing when 2.6.0 derived these, and the
424 // reason a pure-black ink shipped a secondary 3/255 away from it: the
425 // ramp falling monotonically says nothing about how far it falls, and
426 // a step nobody can see is not a step.
427 for (id, toml) in embedded_themes() {
428 let theme = parse_theme_str(id, toml, false).unwrap();
429 let t = resolve(&theme);
430 let ink = Rgb::from_hex(t.hex("content").unwrap()).unwrap();
431 let secondary = Rgb::from_hex(t.hex("content-secondary").unwrap()).unwrap();
432 let step = wcag_contrast(ink, secondary);
433 assert!(
434 step >= STEP_FLOOR,
435 "{id}: secondary is {step:.2} from its ink, under the {STEP_FLOOR} floor"
436 );
437 }
438 }
439
440 #[test]
441 fn an_authored_emphasis_step_does_not_survive_loading() {
442 // `nord_toml` still authors both, because a user's theme file might and
443 // the answer has to be the same one.
444 let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
445 assert_ne!(theme.colors.get("content.muted").unwrap(), "#616e88");
446 assert_ne!(theme.colors.get("content.secondary").unwrap(), "#e5e9f0");
447 }
448
449 #[test]
450 fn a_theme_with_no_page_keeps_what_it_authored() {
451 // Skip-missing: there is nothing to read the step against, so the step
452 // is not taken and a half-written theme does not lose a colour.
453 let mut colors = HashMap::new();
454 colors.insert("content.primary".to_string(), "#d8dee9".to_string());
455 colors.insert("content.muted".to_string(), "#616e88".to_string());
456 derive_tonal_steps(&mut colors);
457 assert_eq!(colors.get("content.muted").unwrap(), "#616e88");
458 }
459
460 // ---- loading / fs ----
461
462 #[test]
463 fn load_and_resolve_round_trip() {
464 let dir = tempfile::tempdir().unwrap();
465 fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
466 let dirs = vec![(dir.path().to_path_buf(), false)];
467 let t = load_semantic(&dirs, "nord").unwrap();
468 assert_eq!(t.meta.name, "Nord");
469 assert_eq!(t.hex("action"), Some("#81a1c1"));
470 }
471
472 #[test]
473 fn load_theme_rejects_invalid_id() {
474 assert!(load_theme(&[], "../evil").is_err());
475 }
476
477 #[test]
478 fn list_themes_from_dirs_finds_toml_files() {
479 let dir = tempfile::tempdir().unwrap();
480 fs::write(dir.path().join("t.toml"), "[meta]\nname = \"T\"\n").unwrap();
481 fs::write(dir.path().join("x.txt"), "ignored").unwrap();
482 let dirs = vec![(dir.path().to_path_buf(), false)];
483 let themes = list_themes_from_dirs(&dirs);
484 assert_eq!(themes.len(), 1);
485 assert_eq!(themes[0].id, "t");
486 }
487
488 #[test]
489 fn import_theme_valid_and_rejects_empty() {
490 let src_dir = tempfile::tempdir().unwrap();
491 let custom_dir = tempfile::tempdir().unwrap();
492
493 let good = src_dir.path().join("my-theme.toml");
494 fs::write(&good, "[surface]\npage = \"#1a1b26\"\n").unwrap();
495 let meta = import_theme(&good, custom_dir.path()).unwrap();
496 assert_eq!(meta.id, "my-theme");
497 assert!(custom_dir.path().join("my-theme.toml").exists());
498
499 let empty = src_dir.path().join("empty.toml");
500 fs::write(&empty, "[meta]\nname = \"E\"\n").unwrap();
501 assert!(import_theme(&empty, custom_dir.path()).is_err());
502 }
503
504 #[test]
505 fn import_theme_rejects_invalid_toml() {
506 let src_dir = tempfile::tempdir().unwrap();
507 let custom_dir = tempfile::tempdir().unwrap();
508 let src = src_dir.path().join("bad.toml");
509 fs::write(&src, "this is not [valid toml [[[").unwrap();
510 assert!(import_theme(&src, custom_dir.path()).is_err());
511 }
512
513 #[test]
514 fn delete_theme_removes_and_guards() {
515 let custom = tempfile::tempdir().unwrap();
516 let path = custom.path().join("doomed.toml");
517 fs::write(&path, "[surface]\npage = \"#000\"\n").unwrap();
518 delete_theme(custom.path(), "doomed").unwrap();
519 assert!(!path.exists());
520 assert!(delete_theme(custom.path(), "../etc/passwd").is_err());
521 assert!(delete_theme(custom.path(), "ghost").is_err());
522 }
523
524 #[test]
525 fn export_theme_copies_file() {
526 let src_dir = tempfile::tempdir().unwrap();
527 let dest_dir = tempfile::tempdir().unwrap();
528 let content = "[meta]\nname = \"E\"\n[surface]\npage = \"#ffffff\"\n";
529 fs::write(src_dir.path().join("e.toml"), content).unwrap();
530 let dirs = vec![(src_dir.path().to_path_buf(), false)];
531 let dest = dest_dir.path().join("out.toml");
532 export_theme(&dirs, "e", &dest).unwrap();
533 assert_eq!(fs::read_to_string(&dest).unwrap(), content);
534 assert!(export_theme(&dirs, "missing", &dest).is_err());
535 }
536
537 #[test]
538 fn load_theme_preview_returns_role_swatches() {
539 let dir = tempfile::tempdir().unwrap();
540 fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
541 let dirs = vec![(dir.path().to_path_buf(), false)];
542 let p = load_theme_preview(&dirs, "nord").unwrap();
543 assert_eq!(p.background.as_deref(), Some("#2e3440")); // surface.page
544 assert_eq!(p.foreground.as_deref(), Some("#d8dee9")); // content.primary
545 assert_eq!(p.accent.as_deref(), Some("#81a1c1")); // action.primary
546 assert_eq!(p.border.as_deref(), Some("#4c566a")); // line.border
547 }
548
549 #[test]
550 fn every_shipped_theme_loads() {
551 // Guards the data, not just the loader: a malformed or truncated
552 // .toml in themes/ is a shipping bug, and it should fail here rather
553 // than at a user's first launch.
554 let dir = bundled_themes_dir().unwrap();
555 let dirs = vec![(dir.clone(), false)];
556 let themes = list_themes_from_dirs(&dirs);
557 assert!(
558 themes.len() >= 30,
559 "expected the full theme set, got {}",
560 themes.len()
561 );
562 for meta in &themes {
563 load_theme(&dirs, &meta.id)
564 .unwrap_or_else(|e| panic!("shipped theme `{}` failed to load: {e}", meta.id));
565 }
566 }
567 }
568