Skip to main content

max / makeover-tui

19.7 KB · 492 lines History Blame Raw
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 /// This theme as the terminal can actually draw it.
202 ///
203 /// At [`TrueColor`](crate::Fidelity::TrueColor) the theme is returned
204 /// untouched. Otherwise every colour becomes a palette index, which is the
205 /// point: left as 24-bit, the terminal approximates them itself, and its
206 /// approximation collapses tones the theme keeps apart. Alloy's console lost
207 /// its frame that way, drawing a border in a colour the Linux console could
208 /// not tell from the page behind it.
209 ///
210 /// Anything that has to be seen against the page is quantised against it
211 /// rather than on its own, so a border stays a border and text stays
212 /// readable. The surfaces themselves are quantised plainly: they are what
213 /// the others are measured against.
214 ///
215 /// The bevel edges are quantised plainly too, for a different reason. They
216 /// are measured against the raised surface they surround rather than against
217 /// the page, and running them through [`Quantize::against`] would push both
218 /// onto the same entry and invert the bevel on one side. At
219 /// [`Ansi16`](crate::Fidelity::Ansi16) the palette cannot hold the pair at
220 /// all and one edge lands back on its face, which is a property of sixteen
221 /// colours rather than something this can fix. A caller drawing there does
222 /// not have to handle it: [`Theme::palette`] carries the fidelity through,
223 /// and [`frame`](crate::frame) answers it with glyphs instead of tones.
224 ///
225 /// A consumer holding tokens of its own quantises them alongside this, with
226 /// the same [`Quantize`], rather than after the fact.
227 #[must_use]
228 pub fn for_terminal(self, fidelity: crate::Fidelity) -> Self {
229 let Some(q) = Quantize::for_fidelity(fidelity) else {
230 return self;
231 };
232
233 let plain = |c: Color| q.plain(c);
234 let on_page = |c: Color| q.against(c, self.surface_page);
235
236 Self {
237 mode: self.mode,
238
239 surface_page: plain(self.surface_page),
240 surface_raised: plain(self.surface_raised),
241 surface_sunken: plain(self.surface_sunken),
242 surface_overlay: plain(self.surface_overlay),
243 // Plainly, like the other surfaces and for the same reason as the
244 // bevel pair: a well is measured against the raised face it is cut
245 // into, not against the page, so quantising it against the page
246 // would push it toward contrast it is not supposed to have.
247 surface_well: self.surface_well.map(plain),
248
249 content_primary: on_page(self.content_primary),
250 content_secondary: on_page(self.content_secondary),
251 content_muted: on_page(self.content_muted),
252
253 action_primary: on_page(self.action_primary),
254
255 status_danger: on_page(self.status_danger),
256 status_success: on_page(self.status_success),
257 status_warning: on_page(self.status_warning),
258 status_info: on_page(self.status_info),
259
260 line_border: on_page(self.line_border),
261 border_strong: on_page(self.border_strong),
262
263 bevel_light: plain(self.bevel_light),
264 bevel_dark: plain(self.bevel_dark),
265
266 category: self.category.map(on_page),
267 }
268 }
269
270 /// The depth-painting palette this theme implies, at `fidelity`.
271 ///
272 /// The bridge between the two halves of this crate: [`Theme`] is what a
273 /// theme file says, [`Palette`](crate::Palette) is the subset
274 /// [`frame`](crate::frame) and [`paint_bevel`](crate::paint_bevel) need. A
275 /// consumer holding a `Theme` should not be assembling that by hand and
276 /// picking the wrong surface for the well.
277 #[must_use]
278 pub const fn palette(&self, fidelity: crate::Fidelity) -> crate::Palette {
279 crate::Palette {
280 page: self.surface_page,
281 raised: self.surface_raised,
282 overlay: self.surface_overlay,
283 well: self.surface_well,
284 bevel_light: self.bevel_light,
285 bevel_dark: self.bevel_dark,
286 fidelity,
287 }
288 }
289 }
290
291 fn rgb(c: Rgb) -> Color {
292 Color::Rgb(c.r, c.g, c.b)
293 }
294
295 /// The palette a [`Fidelity`](crate::Fidelity) quantises into, and the rules for
296 /// landing a colour in it.
297 ///
298 /// Public because a consumer carrying tokens of its own has to quantise them the
299 /// same way this crate quantises the ones it knows about. `alloy_tui` derives a
300 /// decorative divider and a focus ring from the authored border; those are its
301 /// tokens, but "a colour that must stay legible against the page is quantised
302 /// against the page" is not its rule to reinvent.
303 #[derive(Debug, Clone, Copy)]
304 pub struct Quantize {
305 palette: &'static [Rgb],
306 offset: usize,
307 }
308
309 impl Quantize {
310 /// The quantiser for `fidelity`, or `None` at
311 /// [`TrueColor`](crate::Fidelity::TrueColor), where nothing is quantised.
312 ///
313 /// 256 resolves to makeover's fixed region rather than the whole table: the
314 /// low sixteen are repaintable in every emulator, so a match landing there
315 /// is a match against a colour the user may have moved out from under it.
316 #[must_use]
317 pub const fn for_fidelity(fidelity: crate::Fidelity) -> Option<Self> {
318 match fidelity {
319 crate::Fidelity::TrueColor => None,
320 crate::Fidelity::Ansi256 => Some(Self {
321 palette: makeover::ANSI_240,
322 offset: makeover::ANSI_240_OFFSET,
323 }),
324 crate::Fidelity::Ansi16 => Some(Self {
325 palette: &makeover::ANSI_16,
326 offset: 0,
327 }),
328 }
329 }
330
331 /// The palette entry for `c`, as an index the terminal will not reinterpret.
332 ///
333 /// For a colour measured against the surface it sits on rather than against
334 /// the page: the surfaces themselves, and the bevel pair.
335 #[must_use]
336 pub fn plain(&self, c: Color) -> Color {
337 match c {
338 Color::Rgb(r, g, b) => Color::Indexed(
339 (makeover::quantize(Rgb { r, g, b }, self.palette) + self.offset) as u8,
340 ),
341 other => other,
342 }
343 }
344
345 /// As [`plain`](Self::plain), but guaranteed to stay legible against `on`.
346 ///
347 /// Only for a colour whose job is to be told apart from a known background.
348 /// It answers "nearest entry that still contrasts with `on`" and has no
349 /// notion of which side of `on` the answer should fall, so a pair of colours
350 /// that must also stay apart from *each other* is the one thing it must not
351 /// be used for: both get pushed onto the same contrasting entry. That is why
352 /// the bevel edges go through [`plain`](Self::plain).
353 #[must_use]
354 pub fn against(&self, c: Color, on: Color) -> Color {
355 match (c, on) {
356 (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed(
357 (makeover::quantize_against(
358 Rgb { r, g, b },
359 Rgb {
360 r: br,
361 g: bg,
362 b: bb,
363 },
364 self.palette,
365 ) + self.offset) as u8,
366 ),
367 _ => self.plain(c),
368 }
369 }
370 }
371
372 #[cfg(test)]
373 mod tests {
374 use super::*;
375
376 fn bundled(id: &str) -> ThemeColors {
377 let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
378 makeover::load_theme(&[(dir, false)], id).expect("bundled theme loads")
379 }
380
381 #[test]
382 fn every_bundled_theme_resolves() {
383 // The point of rejecting a partial theme is that it never happens to a
384 // theme we ship. If one of these stops resolving, that is a real gap in
385 // the theme file, not a reason to soften the error.
386 let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
387 let metas = makeover::list_themes_from_dirs(&[(dir, false)]);
388 assert!(
389 !metas.is_empty(),
390 "makeover shipped no themes to test against"
391 );
392 for meta in &metas {
393 let colors = bundled(&meta.id);
394 assert!(
395 Theme::from_theme(&colors).is_ok(),
396 "bundled theme `{}` failed to resolve",
397 meta.id
398 );
399 }
400 }
401
402 #[test]
403 fn a_missing_intent_names_the_key_it_wanted() {
404 let mut colors = bundled("goingson");
405 colors.colors.remove("content.muted");
406 match Theme::from_theme(&colors) {
407 Err(ThemeError::MissingKey(k)) => assert_eq!(k, "content.muted"),
408 other => panic!("expected MissingKey(content.muted), got {other:?}"),
409 }
410 }
411
412 #[test]
413 fn an_unparseable_hex_names_the_key_and_the_value() {
414 let mut colors = bundled("goingson");
415 colors
416 .colors
417 .insert("content.muted".into(), "not-a-colour".into());
418 match Theme::from_theme(&colors) {
419 Err(ThemeError::InvalidHex { key, value }) => {
420 assert_eq!(key, "content.muted");
421 assert_eq!(value, "not-a-colour");
422 }
423 other => panic!("expected InvalidHex, got {other:?}"),
424 }
425 }
426
427 #[test]
428 fn a_capable_terminal_gets_the_theme_as_authored() {
429 let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
430 let same = theme.for_terminal(crate::Fidelity::TrueColor);
431 assert_eq!(same.surface_page, theme.surface_page);
432 assert_eq!(same.content_primary, theme.content_primary);
433 assert!(matches!(same.surface_page, Color::Rgb(..)));
434 }
435
436 #[test]
437 fn a_limited_terminal_gets_indices_rather_than_rgb() {
438 let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
439 for fidelity in [crate::Fidelity::Ansi16, crate::Fidelity::Ansi256] {
440 let q = theme.for_terminal(fidelity);
441 assert!(
442 matches!(q.surface_page, Color::Indexed(_)),
443 "{fidelity:?} left a surface as rgb"
444 );
445 assert!(
446 matches!(q.content_primary, Color::Indexed(_)),
447 "{fidelity:?} left content as rgb"
448 );
449 }
450 }
451
452 #[test]
453 fn the_256_indices_land_outside_the_repaintable_low_sixteen() {
454 // The reason Quantize::for_fidelity resolves 256 to makeover's fixed
455 // region: an index below 16 is one the user's emulator may have moved.
456 let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
457 let q = theme.for_terminal(crate::Fidelity::Ansi256);
458 for (name, c) in [
459 ("surface_page", q.surface_page),
460 ("content_primary", q.content_primary),
461 ("bevel_light", q.bevel_light),
462 ("bevel_dark", q.bevel_dark),
463 ] {
464 match c {
465 Color::Indexed(i) => assert!(i >= 16, "{name} landed on repaintable index {i}"),
466 other => panic!("{name} was not quantised: {other:?}"),
467 }
468 }
469 }
470
471 #[test]
472 fn the_bevel_pair_stays_two_tones_at_256() {
473 // Quantised plainly rather than against the page, precisely so they do
474 // not collapse onto one entry and invert the bevel on one side.
475 let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
476 let q = theme.for_terminal(crate::Fidelity::Ansi256);
477 assert_ne!(q.bevel_light, q.bevel_dark);
478 }
479
480 #[test]
481 fn the_palette_takes_the_well_and_not_the_sunken_surface() {
482 // The substitution this crate deleted from the description, asserted
483 // absent here too: a theme authoring sunken darker than raised would
484 // land the well on the wrong side of its face.
485 let colors = bundled("goingson");
486 let theme = Theme::from_theme(&colors).expect("resolves");
487 let palette = theme.palette(crate::Fidelity::TrueColor);
488 assert_eq!(palette.well, theme.surface_well);
489 assert_ne!(palette.well, Some(theme.surface_sunken));
490 }
491 }
492