|
1 |
+ |
//! A loaded makeover theme, resolved into the colours ratatui draws with.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! Behind the `theme` feature, because it is the one thing here that needs
|
|
4 |
+ |
//! `makeover` itself. The rest of this crate takes [`Color`]s it is handed and
|
|
5 |
+ |
//! never asks where they came from, which keeps a consumer that only wants
|
|
6 |
+ |
//! [`frame`](crate::frame) off the theme loader and its embedded theme files.
|
|
7 |
+ |
//!
|
|
8 |
+ |
//! # Why this lives here rather than in each consumer
|
|
9 |
+ |
//!
|
|
10 |
+ |
//! Reading makeover's intents into ratatui `Color`s is the same work every
|
|
11 |
+ |
//! terminal consumer does, and doing it twice is how two of them end up
|
|
12 |
+ |
//! disagreeing about which intent a surface reads from. The mapping is
|
|
13 |
+ |
//! mechanical, the failure mode is silent, and there is exactly one right
|
|
14 |
+ |
//! answer, so it belongs with the renderer.
|
|
15 |
+ |
//!
|
|
16 |
+ |
//! # What is deliberately absent
|
|
17 |
+ |
//!
|
|
18 |
+ |
//! Tokens a consumer derives for itself. `alloy_tui` mixes a `border-subtle`
|
|
19 |
+ |
//! and its own focus-ring `border-strong` out of the authored border, holding
|
|
20 |
+ |
//! the latter to WCAG AA-UI against the page because Alloy spends it as the
|
|
21 |
+ |
//! entire focus cue. makeover emits a `border-strong` too, and it is a flat 5%
|
|
22 |
+ |
//! darkening: a firmer divider, not a focus ring. Those are different tokens
|
|
23 |
+ |
//! wearing one name, and on Akari Dawn they land at 1.63:1 and 3.27:1. This
|
|
24 |
+ |
//! struct carries makeover's, and a consumer that needs its own keeps deriving
|
|
25 |
+ |
//! it. Adopting one for the other would take a focus ring to half its floor.
|
|
26 |
+ |
|
|
27 |
+ |
use makeover::{Rgb, ThemeColors};
|
|
28 |
+ |
use ratatui::style::Color;
|
|
29 |
+ |
|
|
30 |
+ |
/// A theme's polarity, as its author declared it.
|
|
31 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
32 |
+ |
pub enum Mode {
|
|
33 |
+ |
Light,
|
|
34 |
+ |
Dark,
|
|
35 |
+ |
HighContrast,
|
|
36 |
+ |
}
|
|
37 |
+ |
|
|
38 |
+ |
/// A makeover theme's intents, resolved to ratatui colours.
|
|
39 |
+ |
///
|
|
40 |
+ |
/// `#[non_exhaustive]`: this gains a field whenever makeover gains an intent,
|
|
41 |
+ |
/// and without the attribute every one of those would be a major here. Nothing
|
|
42 |
+ |
/// should be building one field-by-field anyway, since [`Theme::from_theme`] is
|
|
43 |
+ |
/// the only way to get one and a partial theme is an error rather than a
|
|
44 |
+ |
/// default.
|
|
45 |
+ |
#[derive(Debug, Clone, Copy)]
|
|
46 |
+ |
#[non_exhaustive]
|
|
47 |
+ |
pub struct Theme {
|
|
48 |
+ |
pub mode: Mode,
|
|
49 |
+ |
|
|
50 |
+ |
pub surface_page: Color,
|
|
51 |
+ |
pub surface_raised: Color,
|
|
52 |
+ |
pub surface_sunken: Color,
|
|
53 |
+ |
pub surface_overlay: Color,
|
|
54 |
+ |
|
|
55 |
+ |
/// makeover's inset content surface: the surface inside a raised container,
|
|
56 |
+ |
/// so a list reads as content in a container rather than as bands on a
|
|
57 |
+ |
/// panel.
|
|
58 |
+ |
///
|
|
59 |
+ |
/// Not [`surface_sunken`](Theme::surface_sunken). A theme is free to author
|
|
60 |
+ |
/// sunken *darker* than raised while a well always inverts away from the
|
|
61 |
+ |
/// text, so substituting one for the other lands a well on the wrong side of
|
|
62 |
+ |
/// its face on exactly the themes where it matters.
|
|
63 |
+ |
///
|
|
64 |
+ |
/// `None` where makeover derived nothing, which is a theme authoring no
|
|
65 |
+ |
/// raised surface or no content colour. Left missing rather than guessed,
|
|
66 |
+ |
/// the same way [`Palette::fill`](crate::Palette::fill) answers a missing
|
|
67 |
+ |
/// well with structure instead of a substitute colour.
|
|
68 |
+ |
pub surface_well: Option<Color>,
|
|
69 |
+ |
|
|
70 |
+ |
pub content_primary: Color,
|
|
71 |
+ |
pub content_secondary: Color,
|
|
72 |
+ |
pub content_muted: Color,
|
|
73 |
+ |
|
|
74 |
+ |
pub action_primary: Color,
|
|
75 |
+ |
|
|
76 |
+ |
pub status_danger: Color,
|
|
77 |
+ |
pub status_success: Color,
|
|
78 |
+ |
pub status_warning: Color,
|
|
79 |
+ |
pub status_info: Color,
|
|
80 |
+ |
|
|
81 |
+ |
/// The authored border colour.
|
|
82 |
+ |
pub line_border: Color,
|
|
83 |
+ |
/// makeover's derived firmer divider: the authored border, 5% darker.
|
|
84 |
+ |
///
|
|
85 |
+ |
/// A divider, not a focus ring. See the module header before spending it as
|
|
86 |
+ |
/// one.
|
|
87 |
+ |
pub border_strong: Color,
|
|
88 |
+ |
|
|
89 |
+ |
/// The lit and shadowed edges of a raised surface.
|
|
90 |
+ |
///
|
|
91 |
+ |
/// A control is lit from the top left, so its top and left edges take
|
|
92 |
+ |
/// `bevel_light` and its bottom and right edges `bevel_dark`; swapping the
|
|
93 |
+ |
/// two recesses it, which is what a pressed state and a text well are. The
|
|
94 |
+ |
/// light source does not flip with polarity, or the rule stops transferring
|
|
95 |
+ |
/// between widgets, which is the whole reason to have one.
|
|
96 |
+ |
pub bevel_light: Color,
|
|
97 |
+ |
pub bevel_dark: Color,
|
|
98 |
+ |
|
|
99 |
+ |
pub category: [Color; 6],
|
|
100 |
+ |
}
|
|
101 |
+ |
|
|
102 |
+ |
/// Why a theme could not be resolved.
|
|
103 |
+ |
///
|
|
104 |
+ |
/// Both variants name the key, because "the theme is bad" is not something a
|
|
105 |
+ |
/// user can act on and "the theme is missing `content.muted`" is.
|
|
106 |
+ |
#[derive(Debug, Clone)]
|
|
107 |
+ |
pub enum ThemeError {
|
|
108 |
+ |
MissingKey(&'static str),
|
|
109 |
+ |
InvalidHex { key: &'static str, value: String },
|
|
110 |
+ |
}
|
|
111 |
+ |
|
|
112 |
+ |
impl std::fmt::Display for ThemeError {
|
|
113 |
+ |
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
114 |
+ |
match self {
|
|
115 |
+ |
Self::MissingKey(k) => write!(f, "theme missing required key `{k}`"),
|
|
116 |
+ |
Self::InvalidHex { key, value } => {
|
|
117 |
+ |
write!(f, "theme key `{key}` has invalid hex value `{value}`")
|
|
118 |
+ |
}
|
|
119 |
+ |
}
|
|
120 |
+ |
}
|
|
121 |
+ |
}
|
|
122 |
+ |
|
|
123 |
+ |
impl std::error::Error for ThemeError {}
|
|
124 |
+ |
|
|
125 |
+ |
impl Theme {
|
|
126 |
+ |
/// Resolve a loaded [`ThemeColors`] into the colours ratatui draws with.
|
|
127 |
+ |
///
|
|
128 |
+ |
/// Every intent this struct names is required, apart from
|
|
129 |
+ |
/// [`surface_well`](Theme::surface_well), which makeover derives only when
|
|
130 |
+ |
/// the theme gave it enough to derive from. A malformed or partial theme is
|
|
131 |
+ |
/// rejected rather than papered over with defaults: rendering in colours
|
|
132 |
+ |
/// that appear nowhere in the theme file is worse than refusing to render.
|
|
133 |
+ |
pub fn from_theme(theme: &ThemeColors) -> Result<Self, ThemeError> {
|
|
134 |
+ |
let authored = |key: &'static str| -> Result<Rgb, ThemeError> {
|
|
135 |
+ |
let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?;
|
|
136 |
+ |
Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
|
|
137 |
+ |
key,
|
|
138 |
+ |
value: hex.clone(),
|
|
139 |
+ |
})
|
|
140 |
+ |
};
|
|
141 |
+ |
|
|
142 |
+ |
// The bevel pair, the well and the firm border are makeover's derived
|
|
143 |
+ |
// intents, so a console, a webview and an egui app light a raised
|
|
144 |
+ |
// surface the same way. Read through `resolve` rather than recomputed
|
|
145 |
+ |
// here, which is the point of them living in that crate.
|
|
146 |
+ |
let resolved = makeover::resolve(theme);
|
|
147 |
+ |
let derived = |key: &'static str| -> Result<Rgb, ThemeError> {
|
|
148 |
+ |
let hex = resolved.hex(key).ok_or(ThemeError::MissingKey(key))?;
|
|
149 |
+ |
Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
|
|
150 |
+ |
key,
|
|
151 |
+ |
value: hex.to_string(),
|
|
152 |
+ |
})
|
|
153 |
+ |
};
|
|
154 |
+ |
|
|
155 |
+ |
let mode = match theme.meta.variant.as_str() {
|
|
156 |
+ |
"dark" => Mode::Dark,
|
|
157 |
+ |
"high-contrast" => Mode::HighContrast,
|
|
158 |
+ |
_ => Mode::Light,
|
|
159 |
+ |
};
|
|
160 |
+ |
|
|
161 |
+ |
Ok(Self {
|
|
162 |
+ |
mode,
|
|
163 |
+ |
|
|
164 |
+ |
surface_page: rgb(authored("surface.page")?),
|
|
165 |
+ |
surface_raised: rgb(authored("surface.raised")?),
|
|
166 |
+ |
surface_sunken: rgb(authored("surface.sunken")?),
|
|
167 |
+ |
surface_overlay: rgb(authored("surface.overlay")?),
|
|
168 |
+ |
surface_well: resolved
|
|
169 |
+ |
.hex("surface-well")
|
|
170 |
+ |
.and_then(Rgb::from_hex)
|
|
171 |
+ |
.map(rgb),
|
|
172 |
+ |
|
|
173 |
+ |
content_primary: rgb(authored("content.primary")?),
|
|
174 |
+ |
content_secondary: rgb(authored("content.secondary")?),
|
|
175 |
+ |
content_muted: rgb(authored("content.muted")?),
|
|
176 |
+ |
|
|
177 |
+ |
action_primary: rgb(authored("action.primary")?),
|
|
178 |
+ |
|
|
179 |
+ |
status_danger: rgb(authored("status.danger")?),
|
|
180 |
+ |
status_success: rgb(authored("status.success")?),
|
|
181 |
+ |
status_warning: rgb(authored("status.warning")?),
|
|
182 |
+ |
status_info: rgb(authored("status.info")?),
|
|
183 |
+ |
|
|
184 |
+ |
line_border: rgb(authored("line.border")?),
|
|
185 |
+ |
border_strong: rgb(derived("border-strong")?),
|
|
186 |
+ |
|
|
187 |
+ |
bevel_light: rgb(derived("bevel-light")?),
|
|
188 |
+ |
bevel_dark: rgb(derived("bevel-dark")?),
|
|
189 |
+ |
|
|
190 |
+ |
category: [
|
|
191 |
+ |
rgb(authored("category.one")?),
|
|
192 |
+ |
rgb(authored("category.two")?),
|
|
193 |
+ |
rgb(authored("category.three")?),
|
|
194 |
+ |
rgb(authored("category.four")?),
|
|
195 |
+ |
rgb(authored("category.five")?),
|
|
196 |
+ |
rgb(authored("category.six")?),
|
|
197 |
+ |
],
|
|
198 |
+ |
})
|
|
199 |
+ |
}
|
|
200 |
+ |
|
|
201 |
+ |
/// The depth-painting palette this theme implies, at `fidelity`.
|
|
202 |
+ |
///
|
|
203 |
+ |
/// The bridge between the two halves of this crate: [`Theme`] is what a
|
|
204 |
+ |
/// theme file says, [`Palette`](crate::Palette) is the subset
|
|
205 |
+ |
/// [`frame`](crate::frame) and [`paint_bevel`](crate::paint_bevel) need. A
|
|
206 |
+ |
/// consumer holding a `Theme` should not be assembling that by hand and
|
|
207 |
+ |
/// picking the wrong surface for the well.
|
|
208 |
+ |
#[must_use]
|
|
209 |
+ |
pub const fn palette(&self, fidelity: crate::Fidelity) -> crate::Palette {
|
|
210 |
+ |
crate::Palette {
|
|
211 |
+ |
page: self.surface_page,
|
|
212 |
+ |
raised: self.surface_raised,
|
|
213 |
+ |
overlay: self.surface_overlay,
|
|
214 |
+ |
well: self.surface_well,
|
|
215 |
+ |
bevel_light: self.bevel_light,
|
|
216 |
+ |
bevel_dark: self.bevel_dark,
|
|
217 |
+ |
fidelity,
|
|
218 |
+ |
}
|
|
219 |
+ |
}
|
|
220 |
+ |
}
|
|
221 |
+ |
|
|
222 |
+ |
fn rgb(c: Rgb) -> Color {
|
|
223 |
+ |
Color::Rgb(c.r, c.g, c.b)
|
|
224 |
+ |
}
|
|
225 |
+ |
|
|
226 |
+ |
#[cfg(test)]
|
|
227 |
+ |
mod tests {
|
|
228 |
+ |
use super::*;
|
|
229 |
+ |
|
|
230 |
+ |
fn bundled(id: &str) -> ThemeColors {
|
|
231 |
+ |
let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
|
|
232 |
+ |
makeover::load_theme(&[(dir, false)], id).expect("bundled theme loads")
|
|
233 |
+ |
}
|
|
234 |
+ |
|
|
235 |
+ |
#[test]
|
|
236 |
+ |
fn every_bundled_theme_resolves() {
|
|
237 |
+ |
// The point of rejecting a partial theme is that it never happens to a
|
|
238 |
+ |
// theme we ship. If one of these stops resolving, that is a real gap in
|
|
239 |
+ |
// the theme file, not a reason to soften the error.
|
|
240 |
+ |
let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
|
|
241 |
+ |
let metas = makeover::list_themes_from_dirs(&[(dir, false)]);
|
|
242 |
+ |
assert!(
|
|
243 |
+ |
!metas.is_empty(),
|
|
244 |
+ |
"makeover shipped no themes to test against"
|
|
245 |
+ |
);
|
|
246 |
+ |
for meta in &metas {
|
|
247 |
+ |
let colors = bundled(&meta.id);
|
|
248 |
+ |
assert!(
|
|
249 |
+ |
Theme::from_theme(&colors).is_ok(),
|
|
250 |
+ |
"bundled theme `{}` failed to resolve",
|
|
251 |
+ |
meta.id
|
|
252 |
+ |
);
|
|
253 |
+ |
}
|
|
254 |
+ |
}
|
|
255 |
+ |
|
|
256 |
+ |
#[test]
|
|
257 |
+ |
fn a_missing_intent_names_the_key_it_wanted() {
|
|
258 |
+ |
let mut colors = bundled("goingson");
|
|
259 |
+ |
colors.colors.remove("content.muted");
|
|
260 |
+ |
match Theme::from_theme(&colors) {
|
|
261 |
+ |
Err(ThemeError::MissingKey(k)) => assert_eq!(k, "content.muted"),
|
|
262 |
+ |
other => panic!("expected MissingKey(content.muted), got {other:?}"),
|
|
263 |
+ |
}
|
|
264 |
+ |
}
|
|
265 |
+ |
|
|
266 |
+ |
#[test]
|
|
267 |
+ |
fn an_unparseable_hex_names_the_key_and_the_value() {
|
|
268 |
+ |
let mut colors = bundled("goingson");
|
|
269 |
+ |
colors
|
|
270 |
+ |
.colors
|
|
271 |
+ |
.insert("content.muted".into(), "not-a-colour".into());
|
|
272 |
+ |
match Theme::from_theme(&colors) {
|
|
273 |
+ |
Err(ThemeError::InvalidHex { key, value }) => {
|
|
274 |
+ |
assert_eq!(key, "content.muted");
|
|
275 |
+ |
assert_eq!(value, "not-a-colour");
|
|
276 |
+ |
}
|
|
277 |
+ |
other => panic!("expected InvalidHex, got {other:?}"),
|
|
278 |
+ |
}
|
|
279 |
+ |
}
|
|
280 |
+ |
|
|
281 |
+ |
#[test]
|
|
282 |
+ |
fn the_palette_takes_the_well_and_not_the_sunken_surface() {
|
|
283 |
+ |
// The substitution this crate deleted from the description, asserted
|
|
284 |
+ |
// absent here too: a theme authoring sunken darker than raised would
|
|
285 |
+ |
// land the well on the wrong side of its face.
|
|
286 |
+ |
let colors = bundled("goingson");
|
|
287 |
+ |
let theme = Theme::from_theme(&colors).expect("resolves");
|
|
288 |
+ |
let palette = theme.palette(crate::Fidelity::TrueColor);
|
|
289 |
+ |
assert_eq!(palette.well, theme.surface_well);
|
|
290 |
+ |
assert_ne!(palette.well, Some(theme.surface_sunken));
|
|
291 |
+ |
}
|
|
292 |
+ |
}
|