Skip to main content

makeover_layout/
theme_choice.rs

1// Names this module's prose links to, resolved for rustdoc.
2#[allow(unused_imports)]
3use crate::{Choice, Field};
4
5/// Which ambient mode a theme is written for.
6///
7/// The vocabulary's own spelling of what `makeover` calls a theme's variant,
8/// and the duplication is deliberate rather than an oversight. This crate has
9/// no dependencies by charter — it emits nothing, reads nothing and resolves
10/// nothing — so it cannot take the crate that owns the file format, and a
11/// renderer that must group a picker needs the three groups as values.
12///
13/// The two are kept in step by the app that converts between them, which is a
14/// three-arm `match` at each adopter and the price of the layering. If a fourth
15/// mode is ever authored, this enum and `makeover::Variant` move together.
16///
17/// Three, not two: one shipped theme is high contrast, and an app matching on
18/// light-or-dark alone files it under the wrong one.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20#[non_exhaustive]
21pub enum ThemeVariant {
22    /// Written for a light ambient mode.
23    Light,
24    /// Written for a dark ambient mode.
25    Dark,
26    /// Written to be legible before it is pretty.
27    HighContrast,
28}
29
30impl ThemeVariant {
31    /// The machine spelling, matching the theme file's own `meta.variant`.
32    ///
33    /// A data attribute, a stored value, a test assertion. Not a heading: what
34    /// a group is *called* on screen is [`heading`](Self::heading).
35    #[must_use]
36    pub const fn as_str(self) -> &'static str {
37        match self {
38            ThemeVariant::Light => "light",
39            ThemeVariant::Dark => "dark",
40            ThemeVariant::HighContrast => "high-contrast",
41        }
42    }
43
44    /// What the group of themes in this variant is called on screen.
45    ///
46    /// Here rather than at each renderer, which is the whole argument for the
47    /// member existing: three renderers picking their own headings is one
48    /// picker reading three ways, and the spellings below are the ones
49    /// goingson's shipped picker used before it was described.
50    #[must_use]
51    pub const fn heading(self) -> &'static str {
52        match self {
53            ThemeVariant::Light => "Light",
54            ThemeVariant::Dark => "Dark",
55            ThemeVariant::HighContrast => "High Contrast",
56        }
57    }
58}
59
60impl std::fmt::Display for ThemeVariant {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.write_str(self.as_str())
63    }
64}
65
66/// How legible a theme measured, as a picker reports it.
67///
68/// A measurement carried into the description, which is unusual here and is the
69/// one case that earns it: the number comes off the theme's resolved colours,
70/// so the layer that loaded the theme is the only party that has it, and an app
71/// re-deriving it would be parsing every theme file a second time to learn what
72/// was already known. What a renderer does with it is a badge beside the name.
73///
74/// Ordered worst-first, matching `makeover::ContrastTier`, so the two sort the
75/// same way and an adopter's `match` cannot invert an ordering by accident.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
77#[non_exhaustive]
78pub enum Contrast {
79    /// Muted text below the 3:1 floor for large text and UI parts.
80    Low,
81    /// Muted text clears 3:1 but not the 4.5:1 bar for normal text.
82    Standard,
83    /// Muted text meets WCAG AA on every panel ground.
84    High,
85}
86
87impl Contrast {
88    /// The machine spelling, for a data attribute or a test.
89    #[must_use]
90    pub const fn as_str(self) -> &'static str {
91        match self {
92            Contrast::Low => "low",
93            Contrast::Standard => "standard",
94            Contrast::High => "high",
95        }
96    }
97
98    /// The short mark shown beside a theme's name.
99    ///
100    /// One spelling for the tree, for [`ThemeVariant::heading`]'s reason. These
101    /// are the marks audiofiles shipped before its picker was described, which
102    /// is the only implementation that ever drew them.
103    ///
104    /// [`Standard`](Self::Standard) is not the absence of a mark: a reader
105    /// scanning a column of badges learns more from three marks than from two
106    /// and a gap, and "OK" is the honest reading of a theme that clears the UI
107    /// floor and misses the text one.
108    #[must_use]
109    pub const fn badge(self) -> &'static str {
110        match self {
111            Contrast::Low => "low",
112            Contrast::Standard => "OK",
113            Contrast::High => "AA",
114        }
115    }
116}
117
118impl std::fmt::Display for Contrast {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        f.write_str(self.as_str())
121    }
122}
123
124/// One theme, as a picker offers it.
125///
126/// Four facts where a [`Choice`] has two, and the two extra ones are why this
127/// is its own type rather than options with the variant folded into the label.
128/// Both are facts the theme layer resolved and neither survives being written
129/// into a string: a group is structure and a badge is a second column.
130///
131/// # No `unavailable`
132///
133/// [`Choice::unavailable`]'s counterpart is absent for its own sibling's
134/// reason. A theme that is installed can be picked, and a theme that is not
135/// installed is not in the list. There is no third state for a reason to
136/// explain.
137///
138/// `#[non_exhaustive]` from birth, so a new member costs no call site.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
140#[non_exhaustive]
141pub struct ThemeChoice<'a> {
142    /// What is submitted, and what the app stores.
143    pub id: &'a str,
144    /// What is read.
145    pub name: &'a str,
146    /// Which group it belongs to.
147    pub variant: ThemeVariant,
148    /// How legible its muted text measured.
149    pub contrast: Contrast,
150}
151
152impl<'a> ThemeChoice<'a> {
153    /// A theme, with everything a picker needs to place and mark it.
154    ///
155    /// Every fact is an argument and none is a builder, which is the opposite
156    /// of [`Choice`]'s arrangement and is deliberate: a theme missing its
157    /// variant has no group to sit in and a theme missing its tier has no badge
158    /// to draw, so both are the control rather than embellishments on it. The
159    /// same reasoning [`Field::range`] applies to its bounds.
160    #[must_use]
161    pub const fn new(
162        id: &'a str,
163        name: &'a str,
164        variant: ThemeVariant,
165        contrast: Contrast,
166    ) -> Self {
167        Self {
168            id,
169            name,
170            variant,
171            contrast,
172        }
173    }
174}