Skip to main content

max / makeover-tui

Add Theme behind the theme feature, release 0.6.0 Resolving makeover's intents into ratatui colours is the same work every terminal consumer does, and doing it twice is how two of them disagree about which intent a surface reads from. Behind a feature because it is the only thing here that needs makeover, which embeds the shipped theme files. Carries makeover's own border-strong, the 5% darkening. A consumer spending that as a focus ring still derives its own; alloy_tui's is pulled most of the way to the text colour and the two are not interchangeable.
Author: Max Johnson <me@maxj.phd> · 2026-08-01 02:12 UTC
Signed with PGP, not checked
Commit: fa2b5063f36d9655a5a52d847aa3002ea93e3c4d
Parent: 2d9211c
3 files changed, +311 insertions, -1 deletion
M Cargo.toml +8 -1
@@ -1,14 +1,21 @@
1 1 [package]
2 2 name = "makeover-tui"
3 - version = "0.5.1"
3 + version = "0.6.0"
4 4 edition = "2024"
5 5 description = "The terminal renderer for makeover-layout, on ratatui. Colour stops being the constraint above 256 entries; geometry never does, because an edge occupies a whole cell on every side."
6 6 license = "MIT"
7 7 repository = "https://makenot.work/git/max/makeover-tui"
8 8
9 + [features]
10 + # Resolving a loaded makeover theme into ratatui colours. Off by default: it is
11 + # the only thing here that needs `makeover`, which embeds the shipped theme
12 + # files, and a consumer that only paints bevels should not carry them.
13 + theme = ["dep:makeover"]
14 +
9 15 [dependencies]
10 16 ratatui = { version = "0.30", default-features = false }
11 17 makeover-layout = "0.6.0"
18 + makeover = { version = "2.4.1", optional = true }
12 19
13 20 [lints.rust]
14 21 unused = "warn"
M src/lib.rs +11
@@ -84,6 +84,17 @@
84 84 /// requirements in step to name the argument it is already being handed.
85 85 pub use makeover_layout;
86 86
87 + /// A loaded makeover theme, resolved to the colours ratatui draws with.
88 + ///
89 + /// Behind the `theme` feature: it is the only thing here that needs `makeover`
90 + /// itself, and that crate embeds the shipped theme files. A consumer that wants
91 + /// [`frame`] and nothing else should not carry them.
92 + #[cfg(feature = "theme")]
93 + pub mod theme;
94 +
95 + #[cfg(feature = "theme")]
96 + pub use theme::{Mode, Theme, ThemeError};
97 +
87 98 /// How many colours the terminal can actually show.
88 99 ///
89 100 /// Only [`Fidelity::Ansi16`] changes what this crate draws. Above it, colour
A src/theme.rs +292
@@ -1,0 +1,292 @@
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 + }