| 1924 |
1924 |
|
themes
|
| 1925 |
1925 |
|
}
|
| 1926 |
1926 |
|
|
|
1927 |
+ |
/// How legible a theme's muted text is, measured rather than declared.
|
|
1928 |
+ |
///
|
|
1929 |
+ |
/// The worst WCAG contrast ratio of `content.muted` against the two panel
|
|
1930 |
+ |
/// grounds a reader actually meets it on, `surface.page` and `surface.sunken`,
|
|
1931 |
+ |
/// bucketed at the two thresholds WCAG 2.x draws. Worst rather than average,
|
|
1932 |
+ |
/// because a theme that is legible on one panel and not the other is a theme
|
|
1933 |
+ |
/// with an illegible panel.
|
|
1934 |
+ |
///
|
|
1935 |
+ |
/// It is measured here rather than authored in the theme file for the reason
|
|
1936 |
+ |
/// the whole crate exists: a curated palette keeps its identity and the reader
|
|
1937 |
+ |
/// still gets told what it costs them. An author cannot mis-declare it, and a
|
|
1938 |
+ |
/// theme edited on disk re-measures on the next scan.
|
|
1939 |
+ |
///
|
|
1940 |
+ |
/// Ordered worst-first, so `sort` puts the most legible theme last and
|
|
1941 |
+ |
/// [`theme_options`] reverses it into what a picker wants at the top.
|
|
1942 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
|
1943 |
+ |
#[serde(rename_all = "kebab-case")]
|
|
1944 |
+ |
pub enum ContrastTier {
|
|
1945 |
+ |
/// Muted text below the 3:1 floor WCAG sets for large text and UI parts.
|
|
1946 |
+ |
Low,
|
|
1947 |
+ |
/// Muted text meets 3:1 but not the 4.5:1 bar for normal text.
|
|
1948 |
+ |
Standard,
|
|
1949 |
+ |
/// Muted text meets WCAG AA on every panel ground, 4.5:1 or better.
|
|
1950 |
+ |
High,
|
|
1951 |
+ |
}
|
|
1952 |
+ |
|
|
1953 |
+ |
impl ContrastTier {
|
|
1954 |
+ |
/// The machine spelling, for a data attribute or a stored value.
|
|
1955 |
+ |
#[must_use]
|
|
1956 |
+ |
pub const fn as_str(self) -> &'static str {
|
|
1957 |
+ |
match self {
|
|
1958 |
+ |
ContrastTier::Low => "low",
|
|
1959 |
+ |
ContrastTier::Standard => "standard",
|
|
1960 |
+ |
ContrastTier::High => "high",
|
|
1961 |
+ |
}
|
|
1962 |
+ |
}
|
|
1963 |
+ |
|
|
1964 |
+ |
/// Measure a loaded theme.
|
|
1965 |
+ |
///
|
|
1966 |
+ |
/// A theme missing either ground or the muted content colour reads as
|
|
1967 |
+ |
/// [`Standard`](Self::Standard): the measurement did not happen, and
|
|
1968 |
+ |
/// claiming `Low` would badge a theme for the scan's failure rather than
|
|
1969 |
+ |
/// its own.
|
|
1970 |
+ |
#[must_use]
|
|
1971 |
+ |
pub fn of(theme: &ThemeColors) -> Self {
|
|
1972 |
+ |
let colour = |key: &str| theme.colors.get(key).and_then(|v| Rgb::from_hex(v));
|
|
1973 |
+ |
let (Some(muted), Some(page), Some(sunken)) = (
|
|
1974 |
+ |
colour("content.muted"),
|
|
1975 |
+ |
colour("surface.page"),
|
|
1976 |
+ |
colour("surface.sunken"),
|
|
1977 |
+ |
) else {
|
|
1978 |
+ |
return ContrastTier::Standard;
|
|
1979 |
+ |
};
|
|
1980 |
+ |
|
|
1981 |
+ |
let worst = wcag_contrast(muted, page).min(wcag_contrast(muted, sunken));
|
|
1982 |
+ |
if worst >= 4.5 {
|
|
1983 |
+ |
ContrastTier::High
|
|
1984 |
+ |
} else if worst >= 3.0 {
|
|
1985 |
+ |
ContrastTier::Standard
|
|
1986 |
+ |
} else {
|
|
1987 |
+ |
ContrastTier::Low
|
|
1988 |
+ |
}
|
|
1989 |
+ |
}
|
|
1990 |
+ |
}
|
|
1991 |
+ |
|
|
1992 |
+ |
/// One theme, as a picker offers it.
|
|
1993 |
+ |
///
|
|
1994 |
+ |
/// [`ThemeMeta`] plus the two facts a picker needs and a scan is what supplies:
|
|
1995 |
+ |
/// the variant as a value rather than a string, and the measured contrast tier.
|
|
1996 |
+ |
/// Owned, because it outlives the directory scan that produced it and is held
|
|
1997 |
+ |
/// by an app across the frames or requests that draw the control.
|
|
1998 |
+ |
///
|
|
1999 |
+ |
/// It carries no `is_custom`. A picker that sorted the user's own themes apart
|
|
2000 |
+ |
/// from the shipped ones would be answering a different question, and
|
|
2001 |
+ |
/// [`ThemeMeta`] is still there for a screen that wants it.
|
|
2002 |
+ |
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
2003 |
+ |
#[serde(rename_all = "camelCase")]
|
|
2004 |
+ |
pub struct ThemeOption {
|
|
2005 |
+ |
/// The id stored, and the value the picker submits.
|
|
2006 |
+ |
pub id: String,
|
|
2007 |
+ |
/// What the picker reads.
|
|
2008 |
+ |
pub name: String,
|
|
2009 |
+ |
/// Which group it belongs to.
|
|
2010 |
+ |
pub variant: Variant,
|
|
2011 |
+ |
/// How legible its muted text measured.
|
|
2012 |
+ |
pub contrast: ContrastTier,
|
|
2013 |
+ |
}
|
|
2014 |
+ |
|
|
2015 |
+ |
/// Every installed theme, in the order a picker should offer them.
|
|
2016 |
+ |
///
|
|
2017 |
+ |
/// This is the half of a theme picker that is not the control: which themes
|
|
2018 |
+ |
/// exist, which group each is in, how legible each one is, and what order that
|
|
2019 |
+ |
/// puts them in. Three apps derived it three ways and two of them lost it
|
|
2020 |
+ |
/// entirely when their pickers were described, which is what makes it the
|
|
2021 |
+ |
/// crate's job rather than each app's.
|
|
2022 |
+ |
///
|
|
2023 |
+ |
/// # The order
|
|
2024 |
+ |
///
|
|
2025 |
+ |
/// By variant in [`Variant`]'s own order — light, dark, high contrast — then
|
|
2026 |
+ |
/// by measured contrast **best first**, then by name. The middle key is the one
|
|
2027 |
+ |
/// no app can supply without redoing the work this crate has already done: the
|
|
2028 |
+ |
/// tier comes off the resolved colours, and an app sorting a `Vec<ThemeMeta>`
|
|
2029 |
+ |
/// has only the names.
|
|
2030 |
+ |
///
|
|
2031 |
+ |
/// Grouping is left implicit in the order rather than returned as groups. A
|
|
2032 |
+ |
/// renderer that draws headings walks the run of one variant; one that cannot
|
|
2033 |
+ |
/// draw headings still gets the useful order. Handing back
|
|
2034 |
+ |
/// `Vec<(Variant, Vec<ThemeOption>)>` would force the second renderer to
|
|
2035 |
+ |
/// flatten what the first wanted, and neither shape is more true.
|
|
2036 |
+ |
///
|
|
2037 |
+ |
/// # What it costs
|
|
2038 |
+ |
///
|
|
2039 |
+ |
/// Every theme file is parsed twice: once by [`list_themes_from_dirs`] for its
|
|
2040 |
+ |
/// metadata, once here for the colours the tier is measured from. Measured
|
|
2041 |
+ |
/// rather than assumed to be cheap: a picker is drawn on a settings screen, the
|
|
2042 |
+ |
/// shipped set is around twenty files, and the alternative is caching a
|
|
2043 |
+ |
/// derived value that a theme edited on disk would then be wrong about.
|
|
2044 |
+ |
/// A theme whose colours will not load keeps its metadata and reads as
|
|
2045 |
+ |
/// [`ContrastTier::Standard`], on the same footing as one missing a ground.
|
|
2046 |
+ |
///
|
|
2047 |
+ |
/// A host whose themes are not all on disk builds its own [`ThemeOption`]s and
|
|
2048 |
+ |
/// calls [`order_theme_options`], which is this function's second half.
|
|
2049 |
+ |
#[must_use]
|
|
2050 |
+ |
pub fn theme_options(dirs: &[(PathBuf, bool)]) -> Vec<ThemeOption> {
|
|
2051 |
+ |
let mut options: Vec<ThemeOption> = list_themes_from_dirs(dirs)
|
|
2052 |
+ |
.into_iter()
|
|
2053 |
+ |
.map(|meta| {
|
|
2054 |
+ |
let contrast = load_theme(dirs, &meta.id)
|
|
2055 |
+ |
.map_or(ContrastTier::Standard, |theme| ContrastTier::of(&theme));
|
|
2056 |
+ |
ThemeOption {
|
|
2057 |
+ |
variant: meta.kind(),
|
|
2058 |
+ |
contrast,
|
|
2059 |
+ |
id: meta.id,
|
|
2060 |
+ |
name: meta.name,
|
|
2061 |
+ |
}
|
|
2062 |
+ |
})
|
|
2063 |
+ |
.collect();
|
|
2064 |
+ |
|
|
2065 |
+ |
order_theme_options(&mut options);
|
|
2066 |
+ |
options
|
|
2067 |
+ |
}
|
|
2068 |
+ |
|
|
2069 |
+ |
/// Put an already-collected set into the order a picker offers them in.
|
|
2070 |
+ |
///
|
|
2071 |
+ |
/// [`theme_options`]' second half, reachable on its own because not every host
|
|
2072 |
+ |
/// resolves its themes by scanning a directory. audiofiles embeds its shipped
|
|
2073 |
+ |
/// set at compile time and reads only its custom themes off disk, so a
|
|
2074 |
+ |
/// directory scan cannot see most of what it offers, and the alternative to
|
|
2075 |
+ |
/// this being public was that app re-deriving the sort — which is exactly the
|
|
2076 |
+ |
/// three-apps-three-orders state the picker was described to end.
|
|
2077 |
+ |
///
|
|
2078 |
+ |
/// The order is by variant in [`Variant`]'s own order, then by measured
|
|
2079 |
+ |
/// contrast **best first**, then by name.
|
|
2080 |
+ |
pub fn order_theme_options(options: &mut [ThemeOption]) {
|
|
2081 |
+ |
options.sort_by(|a, b| {
|
|
2082 |
+ |
variant_order(a.variant)
|
|
2083 |
+ |
.cmp(&variant_order(b.variant))
|
|
2084 |
+ |
.then(b.contrast.cmp(&a.contrast))
|
|
2085 |
+ |
.then_with(|| a.name.cmp(&b.name))
|
|
2086 |
+ |
});
|
|
2087 |
+ |
}
|
|
2088 |
+ |
|
|
2089 |
+ |
/// Where a variant sits in a picker, light first.
|
|
2090 |
+ |
///
|
|
2091 |
+ |
/// Not `Variant as usize`: the declaration order of an enum is not a promise
|
|
2092 |
+ |
/// about how it reads, and a member inserted for a fourth variant would
|
|
2093 |
+ |
/// silently reorder every picker in the tree.
|
|
2094 |
+ |
const fn variant_order(variant: Variant) -> u8 {
|
|
2095 |
+ |
match variant {
|
|
2096 |
+ |
Variant::Light => 0,
|
|
2097 |
+ |
Variant::Dark => 1,
|
|
2098 |
+ |
Variant::HighContrast => 2,
|
|
2099 |
+ |
}
|
|
2100 |
+ |
}
|
|
2101 |
+ |
|
| 1927 |
2102 |
|
/// Find a theme file by ID in the given directories.
|
| 1928 |
2103 |
|
///
|
| 1929 |
2104 |
|
/// Checks directories in reverse order so the highest-priority directory wins.
|
| 3826 |
4001 |
|
.unwrap_or_else(|e| panic!("shipped theme `{}` failed to load: {e}", meta.id));
|
| 3827 |
4002 |
|
}
|
| 3828 |
4003 |
|
}
|
|
4004 |
+ |
|
|
4005 |
+ |
#[test]
|
|
4006 |
+ |
fn theme_options_groups_by_variant_light_first() {
|
|
4007 |
+ |
let dirs = vec![(bundled_themes_dir().unwrap(), false)];
|
|
4008 |
+ |
let options = theme_options(&dirs);
|
|
4009 |
+ |
assert!(!options.is_empty(), "the shipped set is not empty");
|
|
4010 |
+ |
|
|
4011 |
+ |
let order: Vec<u8> = options.iter().map(|o| variant_order(o.variant)).collect();
|
|
4012 |
+ |
let mut sorted = order.clone();
|
|
4013 |
+ |
sorted.sort_unstable();
|
|
4014 |
+ |
assert_eq!(
|
|
4015 |
+ |
order, sorted,
|
|
4016 |
+ |
"every variant should occupy one run, light first"
|
|
4017 |
+ |
);
|
|
4018 |
+ |
}
|
|
4019 |
+ |
|
|
4020 |
+ |
#[test]
|
|
4021 |
+ |
fn theme_options_puts_the_most_legible_theme_first_in_its_group() {
|
|
4022 |
+ |
let dirs = vec![(bundled_themes_dir().unwrap(), false)];
|
|
4023 |
+ |
let options = theme_options(&dirs);
|
|
4024 |
+ |
|
|
4025 |
+ |
for pair in options.windows(2) {
|
|
4026 |
+ |
let (a, b) = (&pair[0], &pair[1]);
|
|
4027 |
+ |
if a.variant != b.variant {
|
|
4028 |
+ |
continue;
|
|
4029 |
+ |
}
|
|
4030 |
+ |
assert!(
|
|
4031 |
+ |
a.contrast >= b.contrast,
|
|
4032 |
+ |
"within {}, {} ({:?}) should not follow {} ({:?})",
|
|
4033 |
+ |
a.variant,
|
|
4034 |
+ |
b.id,
|
|
4035 |
+ |
b.contrast,
|
|
4036 |
+ |
a.id,
|
|
4037 |
+ |
a.contrast
|
|
4038 |
+ |
);
|
|
4039 |
+ |
if a.contrast == b.contrast {
|
|
4040 |
+ |
assert!(
|
|
4041 |
+ |
a.name <= b.name,
|
|
4042 |
+ |
"ties break by name: {} then {}",
|
|
4043 |
+ |
a.name,
|
|
4044 |
+ |
b.name
|
|
4045 |
+ |
);
|
|
4046 |
+ |
}
|
|
4047 |
+ |
}
|
|
4048 |
+ |
}
|
|
4049 |
+ |
|
|
4050 |
+ |
#[test]
|
|
4051 |
+ |
fn theme_options_carries_every_theme_the_scan_found() {
|
|
4052 |
+ |
let dirs = vec![(bundled_themes_dir().unwrap(), false)];
|
|
4053 |
+ |
let mut scanned: Vec<String> = list_themes_from_dirs(&dirs)
|
|
4054 |
+ |
.into_iter()
|
|
4055 |
+ |
.map(|meta| meta.id)
|
|
4056 |
+ |
.collect();
|
|
4057 |
+ |
let mut offered: Vec<String> = theme_options(&dirs).into_iter().map(|o| o.id).collect();
|
|
4058 |
+ |
scanned.sort();
|
|
4059 |
+ |
offered.sort();
|
|
4060 |
+ |
assert_eq!(scanned, offered, "ordering must not drop a theme");
|
|
4061 |
+ |
}
|
|
4062 |
+ |
|
|
4063 |
+ |
#[test]
|
|
4064 |
+ |
fn a_theme_that_cannot_be_measured_reads_as_standard() {
|
|
4065 |
+ |
// Not Low: a missing ground is the scan failing, and badging the theme
|
|
4066 |
+ |
// for that would tell the reader something untrue about the theme.
|
|
4067 |
+ |
let theme = ThemeColors {
|
|
4068 |
+ |
meta: ThemeMeta {
|
|
4069 |
+ |
id: "unmeasurable".to_string(),
|
|
4070 |
+ |
name: "Unmeasurable".to_string(),
|
|
4071 |
+ |
variant: "dark".to_string(),
|
|
4072 |
+ |
is_custom: false,
|
|
4073 |
+ |
},
|
|
4074 |
+ |
colors: HashMap::new(),
|
|
4075 |
+ |
};
|
|
4076 |
+ |
assert_eq!(ContrastTier::of(&theme), ContrastTier::Standard);
|
|
4077 |
+ |
}
|
|
4078 |
+ |
|
|
4079 |
+ |
#[test]
|
|
4080 |
+ |
fn the_house_themes_measure_high() {
|
|
4081 |
+ |
// The two we author. Measured 2026-08-28: goingson 6.18/7.01 and
|
|
4082 |
+ |
// audiofiles 6.80/4.78 against page and sunken. A change that drops
|
|
4083 |
+ |
// either below AA is a regression in a theme we control, which is
|
|
4084 |
+ |
// exactly what this crate now knows how to see.
|
|
4085 |
+ |
//
|
|
4086 |
+ |
// `high-contrast` is deliberately not in this list. It measures
|
|
4087 |
+ |
// 4.89/3.53 and therefore reads as Standard: its muted text misses AA
|
|
4088 |
+ |
// on its own sunken panel. That is a finding about the theme file, not
|
|
4089 |
+ |
// about the measurement, and it is filed rather than asserted away.
|
|
4090 |
+ |
let dirs = vec![(bundled_themes_dir().unwrap(), false)];
|
|
4091 |
+ |
for id in ["goingson", "audiofiles"] {
|
|
4092 |
+ |
let theme = load_theme(&dirs, id).expect("shipped");
|
|
4093 |
+ |
assert_eq!(
|
|
4094 |
+ |
ContrastTier::of(&theme),
|
|
4095 |
+ |
ContrastTier::High,
|
|
4096 |
+ |
"{id} is one of ours and should meet AA on both grounds"
|
|
4097 |
+ |
);
|
|
4098 |
+ |
}
|
|
4099 |
+ |
}
|
|
4100 |
+ |
|
|
4101 |
+ |
#[test]
|
|
4102 |
+ |
fn contrast_tiers_order_worst_first() {
|
|
4103 |
+ |
assert!(ContrastTier::Low < ContrastTier::Standard);
|
|
4104 |
+ |
assert!(ContrastTier::Standard < ContrastTier::High);
|
|
4105 |
+ |
}
|
| 3829 |
4106 |
|
}
|