Skip to main content

max / makeover

23.0 KB · 632 lines History Blame Raw
1 //! Choosing a theme.
2 //!
3 //! The file half of this crate was always shared; the *selection* half was not,
4 //! and four apps re-rolled it four ways. GoingsOn stores a "system" sentinel in
5 //! localStorage, Balanced Breakfast treats an absent value as follow-the-system
6 //! and hardcodes two theme ids as its light/dark pair, audiofiles keeps the id
7 //! in a synced SQLite table, and the Alloy console parses COLORFGBG. They also
8 //! disagreed about what a variant string means: this crate defaults a missing
9 //! one to "dark" while alloy_tui parsed an unrecognized one as light.
10 //!
11 //! What cannot be shared is the store — localStorage, a synced config table and
12 //! a TOML file are genuinely different places. What can be shared, and is here,
13 //! is the *meaning*: one vocabulary for variants, one encoding for "what did the
14 //! user choose", and one rule for turning that into an id that exists.
15
16 use crate::{Rgb, ThemeColors, ThemeMeta, list_themes_from_dirs, load_theme, wcag_contrast};
17 use serde::Serialize;
18 use std::path::PathBuf;
19
20 // Names this module's prose links to, resolved for rustdoc.
21 #[allow(unused_imports)]
22 use crate::parse_meta;
23
24 /// A theme's kind, as declared by `meta.variant`.
25 ///
26 /// Three, not two: one shipped theme is `high-contrast`, and an app that
27 /// matched on light-or-dark alone would quietly file it under the wrong one.
28 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
29 #[serde(rename_all = "kebab-case")]
30 pub enum Variant {
31 Light,
32 Dark,
33 HighContrast,
34 }
35
36 impl Variant {
37 /// The spelling used in a theme file and in [`ThemeMeta::variant`].
38 #[must_use]
39 pub const fn as_str(self) -> &'static str {
40 match self {
41 Variant::Light => "light",
42 Variant::Dark => "dark",
43 Variant::HighContrast => "high-contrast",
44 }
45 }
46
47 /// Read a variant string, or `None` if it names none of them.
48 #[must_use]
49 pub fn parse(raw: &str) -> Option<Self> {
50 match raw {
51 "light" => Some(Variant::Light),
52 "dark" => Some(Variant::Dark),
53 "high-contrast" => Some(Variant::HighContrast),
54 _ => None,
55 }
56 }
57 }
58
59 impl std::fmt::Display for Variant {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.write_str(self.as_str())
62 }
63 }
64
65 /// Anything unrecognized reads as dark, which is what [`parse_meta`] already
66 /// does with a missing one. Consumers that guessed light for an unknown string
67 /// were disagreeing with the crate that produced it.
68 impl From<&str> for Variant {
69 fn from(raw: &str) -> Self {
70 Variant::parse(raw).unwrap_or(Variant::Dark)
71 }
72 }
73
74 impl ThemeMeta {
75 /// This theme's variant as a value rather than a string.
76 #[must_use]
77 pub fn kind(&self) -> Variant {
78 Variant::from(self.variant.as_str())
79 }
80 }
81
82 /// The spelling of "follow whatever the system is doing", in every store.
83 pub const FOLLOW: &str = "system";
84
85 /// What the user chose, as opposed to what is being rendered.
86 ///
87 /// The distinction is the whole point: `Follow` is a standing instruction that
88 /// resolves differently as the ambient mode changes, and a `Fixed` id is an
89 /// answer that does not. An app that stored only the rendered id could not tell
90 /// the two apart the next time the system flipped to dark.
91 #[derive(Debug, Clone, PartialEq, Eq, Default)]
92 pub enum ThemeSelection {
93 /// Track the ambient light/dark mode.
94 #[default]
95 Follow,
96 /// Always this theme.
97 Fixed(String),
98 }
99
100 impl ThemeSelection {
101 /// Read a stored selection. An empty or absent value is [`Follow`], which
102 /// is what an app with nothing saved yet should do.
103 ///
104 /// [`Follow`]: ThemeSelection::Follow
105 #[must_use]
106 pub fn parse(raw: Option<&str>) -> Self {
107 match raw.map(str::trim) {
108 None | Some("" | FOLLOW) => ThemeSelection::Follow,
109 Some(id) => ThemeSelection::Fixed(id.to_string()),
110 }
111 }
112
113 /// The string to persist, whatever the store is.
114 #[must_use]
115 pub fn as_str(&self) -> &str {
116 match self {
117 ThemeSelection::Follow => FOLLOW,
118 ThemeSelection::Fixed(id) => id,
119 }
120 }
121
122 /// Turn a selection into a theme id that exists.
123 ///
124 /// `ambient` is the light/dark mode the app learned however it can: a
125 /// `prefers-color-scheme` media query, an OS appearance API, `COLORFGBG`
126 /// from a terminal. `available` is what [`list_themes_from_dirs`] found.
127 ///
128 /// A `Fixed` id that is no longer on disk falls through to the same path as
129 /// `Follow` rather than being returned anyway. Themes are deletable in
130 /// three of the four apps, and handing back an id that will fail to load
131 /// only moves the error somewhere less helpful.
132 ///
133 /// The fallback chain is: the app's own default for the ambient mode if it
134 /// is installed, then any installed theme of that variant, then the app's
135 /// default regardless. The last step means this always returns something,
136 /// and an app with no theme directory at all gets the id it ships with and
137 /// the load error it would have had anyway.
138 #[must_use]
139 pub fn resolve(
140 &self,
141 ambient: Variant,
142 defaults: &ThemeDefaults,
143 available: &[ThemeMeta],
144 ) -> String {
145 let installed = |id: &str| available.iter().any(|meta| meta.id == id);
146
147 if let ThemeSelection::Fixed(id) = self
148 && installed(id)
149 {
150 return id.clone();
151 }
152
153 let preferred = defaults.for_variant(ambient);
154 if installed(preferred) {
155 return preferred.to_string();
156 }
157 available
158 .iter()
159 .find(|meta| meta.kind() == ambient)
160 .map_or_else(|| preferred.to_string(), |meta| meta.id.clone())
161 }
162 }
163
164 impl std::fmt::Display for ThemeSelection {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.write_str(self.as_str())
167 }
168 }
169
170 /// The themes an app falls back to, one per ambient mode.
171 ///
172 /// App-specific on purpose: which theme is "the app's own" is the app's
173 /// identity, not this crate's business. What is shared is everything around it.
174 #[derive(Debug, Clone)]
175 pub struct ThemeDefaults {
176 light: String,
177 dark: String,
178 high_contrast: Option<String>,
179 }
180
181 impl ThemeDefaults {
182 pub fn new(light: impl Into<String>, dark: impl Into<String>) -> Self {
183 Self {
184 light: light.into(),
185 dark: dark.into(),
186 high_contrast: None,
187 }
188 }
189
190 /// Name a theme for a high-contrast ambient mode. Without one, that mode
191 /// falls back to the dark default, which is the safer of the two to read.
192 #[must_use]
193 pub fn high_contrast(mut self, id: impl Into<String>) -> Self {
194 self.high_contrast = Some(id.into());
195 self
196 }
197
198 /// Whether a high-contrast default was named.
199 ///
200 /// [`for_variant`] answers for every mode by falling back to the dark
201 /// theme, which is right for resolving a selection and wrong for emitting
202 /// a `prefers-contrast: more` block: that block would then answer the
203 /// preference with a theme that does not honour it. A caller that renders
204 /// per ambient mode asks this first.
205 ///
206 /// [`for_variant`]: ThemeDefaults::for_variant
207 #[must_use]
208 pub const fn names_high_contrast(&self) -> bool {
209 self.high_contrast.is_some()
210 }
211
212 #[must_use]
213 pub fn for_variant(&self, variant: Variant) -> &str {
214 match variant {
215 Variant::Light => &self.light,
216 Variant::Dark => &self.dark,
217 Variant::HighContrast => self.high_contrast.as_ref().unwrap_or(&self.dark),
218 }
219 }
220 }
221
222 /// How legible a theme's muted text is, measured rather than declared.
223 ///
224 /// The worst WCAG contrast ratio of `content.muted` against the two panel
225 /// grounds a reader actually meets it on, `surface.page` and `surface.sunken`,
226 /// bucketed at the two thresholds WCAG 2.x draws. Worst rather than average,
227 /// because a theme that is legible on one panel and not the other is a theme
228 /// with an illegible panel.
229 ///
230 /// It is measured here rather than authored in the theme file for the reason
231 /// the whole crate exists: a curated palette keeps its identity and the reader
232 /// still gets told what it costs them. An author cannot mis-declare it, and a
233 /// theme edited on disk re-measures on the next scan.
234 ///
235 /// Ordered worst-first, so `sort` puts the most legible theme last and
236 /// [`theme_options`] reverses it into what a picker wants at the top.
237 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
238 #[serde(rename_all = "kebab-case")]
239 pub enum ContrastTier {
240 /// Muted text below the 3:1 floor WCAG sets for large text and UI parts.
241 Low,
242 /// Muted text meets 3:1 but not the 4.5:1 bar for normal text.
243 Standard,
244 /// Muted text meets WCAG AA on every panel ground, 4.5:1 or better.
245 High,
246 }
247
248 impl ContrastTier {
249 /// The machine spelling, for a data attribute or a stored value.
250 #[must_use]
251 pub const fn as_str(self) -> &'static str {
252 match self {
253 ContrastTier::Low => "low",
254 ContrastTier::Standard => "standard",
255 ContrastTier::High => "high",
256 }
257 }
258
259 /// Measure a loaded theme.
260 ///
261 /// A theme missing either ground or the muted content colour reads as
262 /// [`Standard`](Self::Standard): the measurement did not happen, and
263 /// claiming `Low` would badge a theme for the scan's failure rather than
264 /// its own.
265 #[must_use]
266 pub fn of(theme: &ThemeColors) -> Self {
267 let colour = |key: &str| theme.colors.get(key).and_then(|v| Rgb::from_hex(v));
268 let (Some(muted), Some(page), Some(sunken)) = (
269 colour("content.muted"),
270 colour("surface.page"),
271 colour("surface.sunken"),
272 ) else {
273 return ContrastTier::Standard;
274 };
275
276 let worst = wcag_contrast(muted, page).min(wcag_contrast(muted, sunken));
277 if worst >= 4.5 {
278 ContrastTier::High
279 } else if worst >= 3.0 {
280 ContrastTier::Standard
281 } else {
282 ContrastTier::Low
283 }
284 }
285 }
286
287 /// One theme, as a picker offers it.
288 ///
289 /// [`ThemeMeta`] plus the two facts a picker needs and a scan is what supplies:
290 /// the variant as a value rather than a string, and the measured contrast tier.
291 /// Owned, because it outlives the directory scan that produced it and is held
292 /// by an app across the frames or requests that draw the control.
293 ///
294 /// It carries no `is_custom`. A picker that sorted the user's own themes apart
295 /// from the shipped ones would be answering a different question, and
296 /// [`ThemeMeta`] is still there for a screen that wants it.
297 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
298 #[serde(rename_all = "camelCase")]
299 pub struct ThemeOption {
300 /// The id stored, and the value the picker submits.
301 pub id: String,
302 /// What the picker reads.
303 pub name: String,
304 /// Which group it belongs to.
305 pub variant: Variant,
306 /// How legible its muted text measured.
307 pub contrast: ContrastTier,
308 }
309
310 /// Every installed theme, in the order a picker should offer them.
311 ///
312 /// This is the half of a theme picker that is not the control: which themes
313 /// exist, which group each is in, how legible each one is, and what order that
314 /// puts them in. Three apps derived it three ways and two of them lost it
315 /// entirely when their pickers were described, which is what makes it the
316 /// crate's job rather than each app's.
317 ///
318 /// # The order
319 ///
320 /// By variant in [`Variant`]'s own order — light, dark, high contrast — then
321 /// by measured contrast **best first**, then by name. The middle key is the one
322 /// no app can supply without redoing the work this crate has already done: the
323 /// tier comes off the resolved colours, and an app sorting a `Vec<ThemeMeta>`
324 /// has only the names.
325 ///
326 /// Grouping is left implicit in the order rather than returned as groups. A
327 /// renderer that draws headings walks the run of one variant; one that cannot
328 /// draw headings still gets the useful order. Handing back
329 /// `Vec<(Variant, Vec<ThemeOption>)>` would force the second renderer to
330 /// flatten what the first wanted, and neither shape is more true.
331 ///
332 /// # What it costs
333 ///
334 /// Every theme file is parsed twice: once by [`list_themes_from_dirs`] for its
335 /// metadata, once here for the colours the tier is measured from. Measured
336 /// rather than assumed to be cheap: a picker is drawn on a settings screen, the
337 /// shipped set is around twenty files, and the alternative is caching a
338 /// derived value that a theme edited on disk would then be wrong about.
339 /// A theme whose colours will not load keeps its metadata and reads as
340 /// [`ContrastTier::Standard`], on the same footing as one missing a ground.
341 ///
342 /// A host whose themes are not all on disk builds its own [`ThemeOption`]s and
343 /// calls [`order_theme_options`], which is this function's second half.
344 #[must_use]
345 pub fn theme_options(dirs: &[(PathBuf, bool)]) -> Vec<ThemeOption> {
346 let mut options: Vec<ThemeOption> = list_themes_from_dirs(dirs)
347 .into_iter()
348 .map(|meta| {
349 let contrast = load_theme(dirs, &meta.id)
350 .map_or(ContrastTier::Standard, |theme| ContrastTier::of(&theme));
351 ThemeOption {
352 variant: meta.kind(),
353 contrast,
354 id: meta.id,
355 name: meta.name,
356 }
357 })
358 .collect();
359
360 order_theme_options(&mut options);
361 options
362 }
363
364 /// Put an already-collected set into the order a picker offers them in.
365 ///
366 /// [`theme_options`]' second half, reachable on its own because not every host
367 /// resolves its themes by scanning a directory. audiofiles embeds its shipped
368 /// set at compile time and reads only its custom themes off disk, so a
369 /// directory scan cannot see most of what it offers, and the alternative to
370 /// this being public was that app re-deriving the sort — which is exactly the
371 /// three-apps-three-orders state the picker was described to end.
372 ///
373 /// The order is by variant in [`Variant`]'s own order, then by measured
374 /// contrast **best first**, then by name.
375 pub fn order_theme_options(options: &mut [ThemeOption]) {
376 options.sort_by(|a, b| {
377 variant_order(a.variant)
378 .cmp(&variant_order(b.variant))
379 .then(b.contrast.cmp(&a.contrast))
380 .then_with(|| a.name.cmp(&b.name))
381 });
382 }
383
384 /// Where a variant sits in a picker, light first.
385 ///
386 /// Not `Variant as usize`: the declaration order of an enum is not a promise
387 /// about how it reads, and a member inserted for a fourth variant would
388 /// silently reorder every picker in the tree.
389 const fn variant_order(variant: Variant) -> u8 {
390 match variant {
391 Variant::Light => 0,
392 Variant::Dark => 1,
393 Variant::HighContrast => 2,
394 }
395 }
396
397 #[cfg(test)]
398 mod tests {
399 use super::*;
400 use crate::bundled_themes_dir;
401 use std::collections::HashMap;
402
403 fn meta(id: &str, variant: &str) -> ThemeMeta {
404 ThemeMeta {
405 id: id.to_string(),
406 name: id.to_string(),
407 variant: variant.to_string(),
408 is_custom: false,
409 }
410 }
411
412 fn defaults() -> ThemeDefaults {
413 ThemeDefaults::new("flatwhite", "nord")
414 }
415
416 // The three the shipped themes actually declare.
417 #[test]
418 fn every_shipped_variant_parses() {
419 assert_eq!(Variant::parse("light"), Some(Variant::Light));
420 assert_eq!(Variant::parse("dark"), Some(Variant::Dark));
421 assert_eq!(Variant::parse("high-contrast"), Some(Variant::HighContrast));
422 assert_eq!(Variant::parse("sepia"), None);
423 }
424
425 // parse_meta already defaults a *missing* variant to dark, so an
426 // unrecognized one reading as light would have the crate disagreeing with
427 // itself. alloy_tui did exactly that before this existed.
428 #[test]
429 fn an_unrecognized_variant_reads_the_way_a_missing_one_does() {
430 assert_eq!(Variant::from("sepia"), Variant::Dark);
431 assert_eq!(Variant::from(""), Variant::Dark);
432
433 let missing: toml::Table = "[meta]\nname = \"X\"\n".parse().unwrap();
434 assert_eq!(parse_meta("x", &missing, false).kind(), Variant::Dark);
435 }
436
437 #[test]
438 fn a_selection_round_trips_through_any_store() {
439 for (stored, expect) in [
440 (Some("system"), ThemeSelection::Follow),
441 (None, ThemeSelection::Follow),
442 (Some(""), ThemeSelection::Follow),
443 (Some(" "), ThemeSelection::Follow),
444 (Some("nord"), ThemeSelection::Fixed("nord".into())),
445 ] {
446 let parsed = ThemeSelection::parse(stored);
447 assert_eq!(parsed, expect, "{stored:?}");
448 assert_eq!(
449 ThemeSelection::parse(Some(parsed.as_str())),
450 expect,
451 "what is written reads back as what was meant",
452 );
453 }
454 }
455
456 // Nothing saved is follow-the-system, which is what Balanced Breakfast
457 // expressed as an absent value and GoingsOn as a sentinel. Both are now the
458 // same thing.
459 #[test]
460 fn nothing_chosen_yet_is_follow() {
461 assert_eq!(ThemeSelection::default(), ThemeSelection::Follow);
462 }
463
464 #[test]
465 fn a_fixed_selection_wins_when_its_theme_is_installed() {
466 let available = [meta("nord", "dark"), meta("flatwhite", "light")];
467 let fixed = ThemeSelection::Fixed("nord".into());
468 assert_eq!(
469 fixed.resolve(Variant::Light, &defaults(), &available),
470 "nord",
471 "a chosen theme is not overridden by the ambient mode",
472 );
473 }
474
475 // Themes are deletable in three of the four apps. Handing back an id that
476 // will fail to load only moves the error somewhere less helpful.
477 #[test]
478 fn a_fixed_selection_whose_theme_is_gone_falls_back() {
479 let available = [meta("nord", "dark"), meta("flatwhite", "light")];
480 let fixed = ThemeSelection::Fixed("deleted".into());
481 assert_eq!(
482 fixed.resolve(Variant::Light, &defaults(), &available),
483 "flatwhite",
484 );
485 }
486
487 #[test]
488 fn follow_picks_the_apps_default_for_the_ambient_mode() {
489 let available = [meta("nord", "dark"), meta("flatwhite", "light")];
490 let follow = ThemeSelection::Follow;
491 assert_eq!(
492 follow.resolve(Variant::Dark, &defaults(), &available),
493 "nord",
494 );
495 assert_eq!(
496 follow.resolve(Variant::Light, &defaults(), &available),
497 "flatwhite",
498 );
499 }
500
501 // The behaviour Balanced Breakfast could not have: following the system
502 // into a theme the user installed, when the app's own default is absent.
503 #[test]
504 fn follow_uses_any_installed_theme_of_the_right_variant() {
505 let available = [meta("solarized-light", "light"), meta("mine", "dark")];
506 assert_eq!(
507 ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available),
508 "mine",
509 "the app's `nord` is not installed, but a dark theme is",
510 );
511 }
512
513 // Always returns something: an app with no theme directory gets the id it
514 // ships with, and the load error it would have had anyway.
515 #[test]
516 fn an_empty_catalog_still_names_the_apps_default() {
517 assert_eq!(
518 ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &[]),
519 "nord",
520 );
521 }
522
523 #[test]
524 fn high_contrast_falls_back_to_dark_unless_named() {
525 let plain = defaults();
526 assert_eq!(plain.for_variant(Variant::HighContrast), "nord");
527
528 let named = defaults().high_contrast("sharp");
529 assert_eq!(named.for_variant(Variant::HighContrast), "sharp");
530 }
531
532 #[test]
533 fn theme_options_groups_by_variant_light_first() {
534 let dirs = vec![(bundled_themes_dir().unwrap(), false)];
535 let options = theme_options(&dirs);
536 assert!(!options.is_empty(), "the shipped set is not empty");
537
538 let order: Vec<u8> = options.iter().map(|o| variant_order(o.variant)).collect();
539 let mut sorted = order.clone();
540 sorted.sort_unstable();
541 assert_eq!(
542 order, sorted,
543 "every variant should occupy one run, light first"
544 );
545 }
546
547 #[test]
548 fn theme_options_puts_the_most_legible_theme_first_in_its_group() {
549 let dirs = vec![(bundled_themes_dir().unwrap(), false)];
550 let options = theme_options(&dirs);
551
552 for pair in options.windows(2) {
553 let (a, b) = (&pair[0], &pair[1]);
554 if a.variant != b.variant {
555 continue;
556 }
557 assert!(
558 a.contrast >= b.contrast,
559 "within {}, {} ({:?}) should not follow {} ({:?})",
560 a.variant,
561 b.id,
562 b.contrast,
563 a.id,
564 a.contrast
565 );
566 if a.contrast == b.contrast {
567 assert!(
568 a.name <= b.name,
569 "ties break by name: {} then {}",
570 a.name,
571 b.name
572 );
573 }
574 }
575 }
576
577 #[test]
578 fn theme_options_carries_every_theme_the_scan_found() {
579 let dirs = vec![(bundled_themes_dir().unwrap(), false)];
580 let mut scanned: Vec<String> = list_themes_from_dirs(&dirs)
581 .into_iter()
582 .map(|meta| meta.id)
583 .collect();
584 let mut offered: Vec<String> = theme_options(&dirs).into_iter().map(|o| o.id).collect();
585 scanned.sort();
586 offered.sort();
587 assert_eq!(scanned, offered, "ordering must not drop a theme");
588 }
589
590 #[test]
591 fn a_theme_that_cannot_be_measured_reads_as_standard() {
592 // Not Low: a missing ground is the scan failing, and badging the theme
593 // for that would tell the reader something untrue about the theme.
594 let theme = ThemeColors {
595 meta: ThemeMeta {
596 id: "unmeasurable".to_string(),
597 name: "Unmeasurable".to_string(),
598 variant: "dark".to_string(),
599 is_custom: false,
600 },
601 colors: HashMap::new(),
602 };
603 assert_eq!(ContrastTier::of(&theme), ContrastTier::Standard);
604 }
605
606 #[test]
607 fn the_house_themes_measure_high() {
608 // The two we author. A change that drops either below AA is a
609 // regression in a theme we control.
610 //
611 // `high-contrast` is deliberately not in this list. It measures
612 // 4.89/3.53 and therefore reads as Standard: its muted text misses AA
613 // on its own sunken panel. That is a finding about the theme file, not
614 // about the measurement, and it is filed rather than asserted away.
615 let dirs = vec![(bundled_themes_dir().unwrap(), false)];
616 for id in ["goingson", "audiofiles"] {
617 let theme = load_theme(&dirs, id).expect("shipped");
618 assert_eq!(
619 ContrastTier::of(&theme),
620 ContrastTier::High,
621 "{id} is one of ours and should meet AA on both grounds"
622 );
623 }
624 }
625
626 #[test]
627 fn contrast_tiers_order_worst_first() {
628 assert!(ContrastTier::Low < ContrastTier::Standard);
629 assert!(ContrastTier::Standard < ContrastTier::High);
630 }
631 }
632