Skip to main content

max / alloy_tui

Take the family Theme from makeover-tui, release 5.0.0 The intent-to-Color mapping and the terminal quantisation were a second copy of makeover-tui's, agreeing with it by convention rather than by construction. Both are gone; Theme now holds a makeover_tui::Theme in a named field beside the two tokens Alloy really does derive for itself. BREAKING: the family intents move behind .makeover. theme.content_muted becomes theme.makeover.content_muted; theme.border_subtle and theme.border_strong are unchanged. Named rather than flattened or reached through Deref on purpose, because makeover's border-strong is a divider and Alloy's is a focus ring, and at every call site it should be impossible to read one for the other. A new test asserts they differ on every bundled theme. Mode and ThemeError are re-exported from makeover-tui rather than restated. The four test fixtures that each wrote the same 21-field literal collapse into one helper, which they had to: makeover_tui::Theme is non_exhaustive, so the literal cannot be written from here at all. Also fixes four broken intra-doc links, two of which predate this.
Author: Max Johnson <me@maxj.phd> · 2026-08-01 13:18 UTC
Signed with PGP, not checked
Commit: 633f6ff65eb7c961a11090fe46db92a651cec0cc
Parent: 7e68698
8 files changed, +239 insertions, -485 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "alloy_tui"
3 - version = "4.1.1"
3 + version = "5.0.0"
4 4 description = "Alloy design system: makeover intents rendered as ratatui Color/Style, plus themed widgets for the alloy console and siblings."
5 5 edition = "2024"
6 6 # 1.88 is ratatui 0.30.2's floor, and 0.30.2 is where `Block::shadow` lands.
@@ -24,7 +24,7 @@
24 24 # because this crate quantises a whole theme off `Fidelity::detect`, and before
25 25 # that release an unrecognised TERM answered Ansi16, which cost a heavier frame
26 26 # when the renderer was the only consumer and costs a flattened theme now.
27 - makeover-tui = "0.5.1"
27 + makeover-tui = { version = "0.7.0", features = ["theme"] }
28 28
29 29 [lints.rust]
30 30 unused = "warn"
M src/bevel.rs +15 -36
@@ -1,7 +1,7 @@
1 1 //! Two-tone bevels: the light model that says a thing can be manipulated.
2 2 //!
3 3 //! A raised control is lit from the top left, so its top and left edges carry
4 - //! [`Theme::bevel_light`] and its bottom and right edges [`Theme::bevel_dark`].
4 + //! [`makeover_tui::Theme::bevel_light`] and its bottom and right edges [`makeover_tui::Theme::bevel_dark`].
5 5 //! Swapping the pair recesses it, which is what a pressed button and a text well
6 6 //! are. One rule, applied without exception, so that a reader who learns it on a
7 7 //! button already knows what a scrollbar trough is telling them.
@@ -32,7 +32,7 @@
32 32 //! and carrying two things this one never had: the fidelity measurements (across
33 33 //! 31 themes, a bevel loses an edge into its face on all of them at sixteen
34 34 //! colours) and a glyph fallback for that case. It also renders
35 - //! [`makeover_layout`]'s description, which is what lets a control light the same
35 + //! [`makeover_tui::makeover_layout`]'s description, which is what lets a control light the same
36 36 //! way in a terminal and in an egui window.
37 37 //!
38 38 //! So the painting moved there, taking this module's half-blocks and split
@@ -152,28 +152,7 @@
152 152 use ratatui::style::Color;
153 153
154 154 fn theme() -> Theme {
155 - crate::theme::Theme {
156 - mode: crate::theme::Mode::Light,
157 - surface_page: Color::Rgb(0, 0, 0),
158 - surface_raised: Color::Rgb(1, 1, 1),
159 - surface_sunken: Color::Rgb(2, 2, 2),
160 - surface_overlay: Color::Rgb(3, 3, 3),
161 - surface_well: Some(Color::Rgb(9, 9, 9)),
162 - content_primary: Color::Rgb(4, 4, 4),
163 - content_secondary: Color::Rgb(5, 5, 5),
164 - content_muted: Color::Rgb(6, 6, 6),
165 - action_primary: Color::Rgb(7, 7, 7),
166 - status_danger: Color::Rgb(8, 8, 8),
167 - status_success: Color::Rgb(9, 9, 9),
168 - status_warning: Color::Rgb(10, 10, 10),
169 - status_info: Color::Rgb(11, 11, 11),
170 - line_border: Color::Rgb(12, 12, 12),
171 - border_subtle: Color::Rgb(13, 13, 13),
172 - border_strong: Color::Rgb(14, 14, 14),
173 - bevel_light: Color::Rgb(16, 16, 16),
174 - bevel_dark: Color::Rgb(17, 17, 17),
175 - category: [Color::Rgb(15, 15, 15); 6],
176 - }
155 + crate::theme::test_theme(crate::theme::Mode::Light)
177 156 }
178 157
179 158 // Fidelity is pinned rather than detected: these assert on glyphs, and at
@@ -213,12 +192,12 @@
213 192 fn raised_is_lit_from_the_top_left() {
214 193 let buf = render(Elevation::Raised, 4, 3);
215 194 let t = theme();
216 - assert_eq!(buf[(0u16, 0u16)].fg, t.bevel_light);
217 - assert_eq!(buf[(1u16, 0u16)].fg, t.bevel_light);
218 - assert_eq!(buf[(0u16, 1u16)].fg, t.bevel_light);
219 - assert_eq!(buf[(3u16, 2u16)].fg, t.bevel_dark);
220 - assert_eq!(buf[(2u16, 2u16)].fg, t.bevel_dark);
221 - assert_eq!(buf[(3u16, 1u16)].fg, t.bevel_dark);
195 + assert_eq!(buf[(0u16, 0u16)].fg, t.makeover.bevel_light);
196 + assert_eq!(buf[(1u16, 0u16)].fg, t.makeover.bevel_light);
197 + assert_eq!(buf[(0u16, 1u16)].fg, t.makeover.bevel_light);
198 + assert_eq!(buf[(3u16, 2u16)].fg, t.makeover.bevel_dark);
199 + assert_eq!(buf[(2u16, 2u16)].fg, t.makeover.bevel_dark);
200 + assert_eq!(buf[(3u16, 1u16)].fg, t.makeover.bevel_dark);
222 201 }
223 202
224 203 // Sunken is the same drawing with the two tones exchanged. Asserted against
@@ -235,8 +214,8 @@
235 214
236 215 let t = theme();
237 216 let swap = |c: Color| match c {
238 - c if c == t.bevel_light => t.bevel_dark,
239 - c if c == t.bevel_dark => t.bevel_light,
217 + c if c == t.makeover.bevel_light => t.makeover.bevel_dark,
218 + c if c == t.makeover.bevel_dark => t.makeover.bevel_light,
240 219 other => other,
241 220 };
242 221 for y in area.y..area.bottom() {
@@ -253,11 +232,11 @@
253 232 let buf = render(Elevation::Raised, 4, 3);
254 233 let t = theme();
255 234 let top_right = &buf[(3u16, 0u16)];
256 - assert_eq!(top_right.fg, t.bevel_light);
257 - assert_eq!(top_right.bg, t.bevel_dark);
235 + assert_eq!(top_right.fg, t.makeover.bevel_light);
236 + assert_eq!(top_right.bg, t.makeover.bevel_dark);
258 237 let bottom_left = &buf[(0u16, 2u16)];
259 - assert_eq!(bottom_left.fg, t.bevel_dark);
260 - assert_eq!(bottom_left.bg, t.bevel_light);
238 + assert_eq!(bottom_left.fg, t.makeover.bevel_dark);
239 + assert_eq!(bottom_left.bg, t.makeover.bevel_light);
261 240 }
262 241
263 242 #[test]
M src/connector.rs +2 -25
@@ -52,7 +52,7 @@
52 52
53 53 let style = Style::default()
54 54 .fg(self.theme.border_strong)
55 - .bg(self.theme.surface_page);
55 + .bg(self.theme.makeover.surface_page);
56 56 let mid = area.x + area.width / 2;
57 57 let last = area.x + area.width - 1;
58 58
@@ -99,32 +99,9 @@
99 99 #[cfg(test)]
100 100 mod tests {
101 101 use super::*;
102 - use crate::theme::Mode;
103 - use ratatui::style::Color;
104 102
105 103 fn theme() -> Theme {
106 - Theme {
107 - mode: Mode::Dark,
108 - surface_page: Color::Rgb(0, 0, 0),
109 - surface_raised: Color::Rgb(1, 1, 1),
110 - surface_sunken: Color::Rgb(2, 2, 2),
111 - surface_overlay: Color::Rgb(3, 3, 3),
112 - surface_well: Some(Color::Rgb(9, 9, 9)),
113 - content_primary: Color::Rgb(4, 4, 4),
114 - content_secondary: Color::Rgb(5, 5, 5),
115 - content_muted: Color::Rgb(6, 6, 6),
116 - action_primary: Color::Rgb(7, 7, 7),
117 - status_danger: Color::Rgb(8, 8, 8),
118 - status_success: Color::Rgb(9, 9, 9),
119 - status_warning: Color::Rgb(10, 10, 10),
120 - status_info: Color::Rgb(11, 11, 11),
121 - line_border: Color::Rgb(12, 12, 12),
122 - border_subtle: Color::Rgb(13, 13, 13),
123 - border_strong: Color::Rgb(14, 14, 14),
124 - bevel_light: Color::Rgb(16, 16, 16),
125 - bevel_dark: Color::Rgb(17, 17, 17),
126 - category: [Color::Rgb(15, 15, 15); 6],
127 - }
104 + crate::theme::test_theme(crate::theme::Mode::Dark)
128 105 }
129 106
130 107 /// Render a connector into a `width`x`height` gutter and read it back as
M src/help.rs +11 -33
@@ -153,8 +153,8 @@
153 153 }
154 154
155 155 let base = Style::default()
156 - .bg(self.theme.surface_overlay)
157 - .fg(self.theme.content_primary);
156 + .bg(self.theme.makeover.surface_overlay)
157 + .fg(self.theme.makeover.content_primary);
158 158
159 159 // The overlay covers what is behind it rather than blending with it: a
160 160 // half-legible keymap over live content is harder to read than either.
@@ -197,13 +197,13 @@
197 197 for b in &group.bindings {
198 198 let (key_style, label_style) = if b.enabled {
199 199 (
200 - base.fg(self.theme.action_primary),
201 - base.fg(self.theme.content_primary),
200 + base.fg(self.theme.makeover.action_primary),
201 + base.fg(self.theme.makeover.content_primary),
202 202 )
203 203 } else {
204 204 (
205 - base.fg(self.theme.content_muted),
206 - base.fg(self.theme.content_muted),
205 + base.fg(self.theme.makeover.content_muted),
206 + base.fg(self.theme.makeover.content_muted),
207 207 )
208 208 };
209 209 let mut spans = vec![
@@ -215,7 +215,7 @@
215 215 if let Some(reason) = b.reason.filter(|_| !b.enabled) {
216 216 spans.push(Span::styled(
217 217 format!(" ({reason})"),
218 - base.fg(self.theme.content_muted),
218 + base.fg(self.theme.makeover.content_muted),
219 219 ));
220 220 }
221 221 lines.push(Line::from(spans));
@@ -232,7 +232,7 @@
232 232 lines.truncate(shown);
233 233 lines.push(Line::from(Span::styled(
234 234 format!(" ... {hidden} more, resize to see"),
235 - base.fg(self.theme.content_muted),
235 + base.fg(self.theme.makeover.content_muted),
236 236 )));
237 237 }
238 238
@@ -243,31 +243,9 @@
243 243 #[cfg(test)]
244 244 mod tests {
245 245 use super::*;
246 - use ratatui::style::Color;
247 246
248 247 fn theme() -> Theme {
249 - Theme {
250 - mode: crate::theme::Mode::Dark,
251 - surface_page: Color::Rgb(0, 0, 0),
252 - surface_raised: Color::Rgb(1, 1, 1),
253 - surface_sunken: Color::Rgb(2, 2, 2),
254 - surface_overlay: Color::Rgb(3, 3, 3),
255 - surface_well: Some(Color::Rgb(9, 9, 9)),
256 - content_primary: Color::Rgb(4, 4, 4),
257 - content_secondary: Color::Rgb(5, 5, 5),
258 - content_muted: Color::Rgb(6, 6, 6),
259 - action_primary: Color::Rgb(7, 7, 7),
260 - status_danger: Color::Rgb(8, 8, 8),
261 - status_success: Color::Rgb(9, 9, 9),
262 - status_warning: Color::Rgb(10, 10, 10),
263 - status_info: Color::Rgb(11, 11, 11),
264 - line_border: Color::Rgb(12, 12, 12),
265 - border_subtle: Color::Rgb(13, 13, 13),
266 - border_strong: Color::Rgb(14, 14, 14),
267 - bevel_light: Color::Rgb(16, 16, 16),
268 - bevel_dark: Color::Rgb(17, 17, 17),
269 - category: [Color::Rgb(15, 15, 15); 6],
270 - }
248 + crate::theme::test_theme(crate::theme::Mode::Dark)
271 249 }
272 250
273 251 fn pane_group() -> Vec<KeyGroup<'static>> {
@@ -337,13 +315,13 @@
337 315
338 316 // Dimmed, and the available one above it is not.
339 317 let x = rendered[row].find('w').expect("the key is drawn") as u16;
340 - assert_eq!(buf[(x, row as u16)].fg, theme.content_muted);
318 + assert_eq!(buf[(x, row as u16)].fg, theme.makeover.content_muted);
341 319 let live = rendered
342 320 .iter()
343 321 .position(|r| r.contains("select"))
344 322 .expect("the available binding is listed");
345 323 let lx = rendered[live].find('j').expect("the key is drawn") as u16;
346 - assert_eq!(buf[(lx, live as u16)].fg, theme.action_primary);
324 + assert_eq!(buf[(lx, live as u16)].fg, theme.makeover.action_primary);
347 325 }
348 326
349 327 // A view without tabs says so rather than advertising two keys that do
@@ -25,12 +25,12 @@
25 25 /// unchanged instead of being drowned by an accent background.
26 26 pub fn selected_style(theme: &Theme) -> Style {
27 27 Style::default()
28 - .bg(theme.surface_raised)
29 - .fg(theme.content_primary)
28 + .bg(theme.makeover.surface_raised)
29 + .fg(theme.makeover.content_primary)
30 30 .add_modifier(Modifier::BOLD)
31 31 }
32 32
33 33 /// Style for an unselected row.
34 34 pub fn unselected_style(theme: &Theme) -> Style {
35 - Style::default().fg(theme.content_secondary)
35 + Style::default().fg(theme.makeover.content_secondary)
36 36 }
M src/text.rs +11 -5
@@ -13,17 +13,23 @@
13 13
14 14 /// Body text in the primary content color.
15 15 pub fn primary(theme: &Theme, s: impl Into<String>) -> Span<'static> {
16 - Span::styled(s.into(), Style::default().fg(theme.content_primary))
16 + Span::styled(
17 + s.into(),
18 + Style::default().fg(theme.makeover.content_primary),
19 + )
17 20 }
18 21
19 22 /// De-emphasized text — labels, units, inactive rows.
20 23 pub fn muted(theme: &Theme, s: impl Into<String>) -> Span<'static> {
21 - Span::styled(s.into(), Style::default().fg(theme.content_muted))
24 + Span::styled(s.into(), Style::default().fg(theme.makeover.content_muted))
22 25 }
23 26
24 27 /// Supporting text: dimmer than primary, louder than muted.
25 28 pub fn secondary(theme: &Theme, s: impl Into<String>) -> Span<'static> {
26 - Span::styled(s.into(), Style::default().fg(theme.content_secondary))
29 + Span::styled(
30 + s.into(),
31 + Style::default().fg(theme.makeover.content_secondary),
32 + )
27 33 }
28 34
29 35 /// Emphasized body text.
@@ -31,7 +37,7 @@
31 37 Span::styled(
32 38 s.into(),
33 39 Style::default()
34 - .fg(theme.content_primary)
40 + .fg(theme.makeover.content_primary)
35 41 .add_modifier(Modifier::BOLD),
36 42 )
37 43 }
@@ -40,5 +46,5 @@
40 46 /// is not a `Severity` — DESIGN-LANGUAGE.md allows the action color here
41 47 /// because a key hint *is* the actionable element, not decoration.
42 48 pub fn action(theme: &Theme, s: impl Into<String>) -> Span<'static> {
43 - Span::styled(s.into(), Style::default().fg(theme.action_primary))
49 + Span::styled(s.into(), Style::default().fg(theme.makeover.action_primary))
44 50 }
M src/theme.rs +134 -309
@@ -1,135 +1,94 @@
1 - //! Theme palette: makeover intents resolved into ratatui `Color`s, plus
2 - //! the two Alloy-derived border tokens.
1 + //! Alloy's theme: the family's resolved intents plus the two border tokens
2 + //! Alloy derives for itself.
3 3 //!
4 - //! Per docs/TOKENS.md, Alloy consumes makeover `.toml` files (the same
5 - //! schema every make-family app already reads) and derives two extra tokens
6 - //! locally so theme files stay minimal and cross-app compatible:
4 + //! The intent-to-`Color` mapping is [`makeover_tui::Theme`]. It used to be a
5 + //! second copy here, and the two agreeing about which intent a surface reads
6 + //! from was a convention rather than a fact. Quantisation moved with it, for the
7 + //! same reason and one more: [`makeover_tui::Theme`] is `#[non_exhaustive]`, so
8 + //! a copy out here could not rebuild a quantised version of it anyway.
9 + //!
10 + //! # The two tokens that stayed, and why they are not duplication
11 + //!
12 + //! Per docs/TOKENS.md, Alloy derives two tokens locally so theme files stay
13 + //! minimal and cross-app compatible:
7 14 //!
8 15 //! - `border-subtle = mix(line.border, surface.page, 60%)` decorative divider
9 16 //! - `border-strong = mix(line.border, content.primary, 65%)` focus / selection
10 17 //!
11 18 //! Mix is in linear sRGB, matching TOKENS.md's worked audit math.
12 19 //!
13 - //! ratatui is immediate-mode with per-widget styling — there is no global
14 - //! visuals object. Widgets in this crate take a `&Theme` at construction time
15 - //! and pull colors from it. Apps build one `Theme` per theme load (via
16 - //! `makeover::load_theme` + `Theme::from_theme`) and thread it through.
20 + //! makeover emits a `border-strong` too, and [`makeover_tui::Theme`] carries it:
21 + //! a flat 5% darkening of the authored border, a slightly firmer divider. Alloy's
22 + //! is pulled most of the way to the text colour because DESIGN-LANGUAGE.md makes
23 + //! it the entire focus cue and TOKENS.md holds it to WCAG AA-UI against the page.
24 + //! On Akari Dawn the two land at 3.27:1 and 1.63:1. They are different tokens
25 + //! wearing one name, and adopting the shared one would take focus to half the
26 + //! required floor.
27 + //!
28 + //! That is exactly why the family intents sit behind a named field rather than
29 + //! being flattened in or reached through `Deref`: at every call site,
30 + //! `theme.border_strong` is Alloy's focus ring and `theme.makeover.border_strong`
31 + //! is the divider, and neither can be mistaken for the other.
32 + //!
33 + //! ratatui is immediate-mode with per-widget styling — there is no global visuals
34 + //! object. Widgets take a `&Theme` at construction and pull colours from it. Apps
35 + //! build one per theme load (`makeover::load_theme` + [`Theme::from_theme`]) and
36 + //! thread it through.
17 37
18 38 use std::sync::OnceLock;
19 39
20 40 use makeover::{Rgb, ThemeColors};
21 - use makeover_tui::Palette;
41 + use makeover_tui::{Palette, Quantize};
22 42 use ratatui::style::Color;
23 43
24 44 /// What the terminal can show, from the family's renderer.
25 45 ///
26 46 /// Re-exported rather than restated. Alloy had its own three-valued `ColorDepth`
27 47 /// with its own `COLORTERM`/`TERM` reading, which is one answer too many now
28 - /// that the rendering goes through `makeover-tui`: the quantization below and
29 - /// the glyph fallback over there have to agree about what the terminal is, and
30 - /// two enums agreeing by convention is how they stop agreeing.
48 + /// that the rendering goes through `makeover-tui`: the quantization there and
49 + /// the glyph fallback there have to agree about what the terminal is, and two
50 + /// enums agreeing by convention is how they stop agreeing.
31 51 pub use makeover_tui::Fidelity;
32 52
33 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
34 - pub enum Mode {
35 - Light,
36 - Dark,
37 - HighContrast,
38 - }
53 + /// A theme's polarity, and why a theme could not be resolved. Both the family's.
54 + pub use makeover_tui::{Mode, ThemeError};
39 55
40 - /// A theme's intents, resolved to the colors ratatui draws with.
56 + /// A theme's intents as Alloy renders them.
41 57 ///
42 - /// `#[non_exhaustive]` because this struct gains a field every time makeover
43 - /// gains an intent, and without the attribute each one of those is a major here.
44 - /// 3.0.0 is itself that major, forced by the bevel pair; the attribute is what
45 - /// stops the next token from forcing another. Added in this release because it
46 - /// is the last moment it is free — nothing outside this crate builds a `Theme`
47 - /// field-by-field today, since [`Theme::from_theme`] is the only sane way to get
48 - /// one and a partial theme is an error rather than a default.
49 - ///
50 - /// The cost is real and accepted: a downstream crate can no longer construct one
51 - /// literally or match it exhaustively. For a palette that is *defined* as
52 - /// however many intents makeover currently has, neither is a thing a consumer
53 - /// should be doing.
58 + /// `#[non_exhaustive]` because Alloy can grow a derived token of its own without
59 + /// that being a major, the same way [`makeover_tui::Theme`] can grow an intent.
54 60 #[derive(Debug, Clone, Copy)]
55 61 #[non_exhaustive]
56 62 pub struct Theme {
57 - pub mode: Mode,
58 -
59 - pub surface_page: Color,
60 - pub surface_raised: Color,
61 - pub surface_sunken: Color,
62 - pub surface_overlay: Color,
63 -
64 - /// makeover's inset content surface: the surface inside a raised container,
65 - /// so a list reads as content in a container rather than as bands on a panel.
63 + /// The family's intents: surfaces, content, status, the bevel pair, and
64 + /// makeover's own `border-strong`.
66 65 ///
67 - /// Not [`surface_sunken`](Theme::surface_sunken), and the distinction is the
68 - /// reason this field exists rather than being aliased onto that one. A theme
69 - /// is free to author sunken *darker* than raised (goingson does) while a well
70 - /// always inverts away from the text, so substituting one for the other lands
71 - /// a well on the wrong side of its face on exactly the themes where it
72 - /// matters. `makeover-tui` deleted that substitution from the description on
73 - /// purpose; reintroducing it here would put it back a layer down.
74 - ///
75 - /// `None` where makeover derived nothing, which is a theme that authors no
76 - /// raised surface or no content color. Left missing rather than guessed, per
77 - /// the same rule: [`Palette::fill`] answers a missing well with structure.
78 - pub surface_well: Option<Color>,
66 + /// Named rather than flattened. See the module header: reading
67 + /// `theme.makeover.border_strong` where you meant Alloy's focus ring is a
68 + /// mistake worth being able to see.
69 + pub makeover: makeover_tui::Theme,
79 70
80 - pub content_primary: Color,
81 - pub content_secondary: Color,
82 - pub content_muted: Color,
83 -
84 - pub action_primary: Color,
85 -
86 - pub status_danger: Color,
87 - pub status_success: Color,
88 - pub status_warning: Color,
89 - pub status_info: Color,
90 -
91 - pub line_border: Color,
71 + /// Alloy's decorative divider. Not a focus ring.
92 72 pub border_subtle: Color,
93 - pub border_strong: Color,
94 -
95 - /// The lit and shadowed edges of a raised surface, from makeover.
73 + /// Alloy's focus and selection border, held to WCAG AA-UI against the page.
96 74 ///
97 - /// A control is lit from the top left, so its top and left edges take
98 - /// `bevel_light` and its bottom and right edges `bevel_dark`; swapping the
99 - /// two recesses it, which is what a pressed state and a text well are. The
100 - /// light source does not flip with the theme's polarity — a dark theme is lit
101 - /// from the same corner, or the rule stops transferring between widgets,
102 - /// which is the whole reason to have one.
103 - pub bevel_light: Color,
104 - pub bevel_dark: Color,
105 -
106 - pub category: [Color; 6],
75 + /// Not [`makeover_tui::Theme::border_strong`], which is a divider.
76 + pub border_strong: Color,
107 77 }
108 78
109 - #[derive(Debug, Clone)]
110 - pub enum ThemeError {
111 - MissingKey(&'static str),
112 - InvalidHex { key: &'static str, value: String },
113 - }
114 -
115 - impl std::fmt::Display for ThemeError {
116 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 - match self {
118 - ThemeError::MissingKey(k) => write!(f, "theme missing required key `{k}`"),
119 - ThemeError::InvalidHex { key, value } => {
120 - write!(f, "theme key `{key}` has invalid hex value `{value}`")
121 - }
122 - }
123 - }
124 - }
125 -
126 - impl std::error::Error for ThemeError {}
127 -
128 79 impl Theme {
129 - /// Resolve a loaded makeover `ThemeColors` into an Alloy `Theme`.
130 - /// Requires every intent Alloy renders — a malformed or partial theme is
131 - /// rejected explicitly rather than silently rendering with defaults.
80 + /// Resolve a loaded makeover `ThemeColors` into Alloy's theme.
81 + ///
82 + /// The family half is [`makeover_tui::Theme::from_theme`], which rejects a
83 + /// partial theme by naming the key it wanted. The two tokens below are then
84 + /// derived from intents that call has already proven present.
132 85 pub fn from_theme(theme: &ThemeColors) -> Result<Self, ThemeError> {
86 + let makeover_theme = makeover_tui::Theme::from_theme(theme)?;
87 +
88 + // Derived in Rgb rather than off the resolved `Color`s: TOKENS.md's
89 + // audit math is in linear sRGB over 8-bit channels, and going through
90 + // ratatui's Color and back would be a round trip for nothing. Each key
91 + // is required, and `from_theme` above already failed if it were absent.
133 92 let get = |key: &'static str| -> Result<Rgb, ThemeError> {
134 93 let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?;
135 94 Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
@@ -137,85 +96,55 @@
137 96 value: hex.clone(),
138 97 })
139 98 };
140 -
141 - // The bevel pair is makeover's, so that a console, a webview and an egui
142 - // app light a raised surface the same way. Read through `resolve` rather
143 - // than recomputed here, which is the point of it living in the crate.
144 - let resolved = makeover::resolve(theme);
145 - let intent = |key: &'static str| -> Result<Rgb, ThemeError> {
146 - let hex = resolved.hex(key).ok_or(ThemeError::MissingKey(key))?;
147 - Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
148 - key,
149 - value: hex.to_string(),
150 - })
151 - };
152 -
153 - let surface_page = get("surface.page")?;
154 - let content_primary = get("content.primary")?;
155 - let line_border = get("line.border")?;
156 -
157 - // These two stay local, and deliberately, though makeover also emits a
158 - // `border-strong`. Its version is a fixed 5% darkening of the authored
159 - // border, which is a slightly firmer divider; this one is pulled most of
160 - // the way to the text color because Alloy spends it on the focus ring,
161 - // where docs/DESIGN-LANGUAGE.md makes it the entire cue and TOKENS.md
162 - // holds it to WCAG AA-UI against the page. On Akari Dawn the two land at
163 - // 3.27:1 and 1.63:1, so they are different tokens wearing one name and
164 - // adopting the shared one would take focus to half the required floor.
165 - let border_subtle = border_subtle(line_border, surface_page);
166 - let border_strong = border_strong(line_border, content_primary);
167 -
168 - let mode = match theme.meta.variant.as_str() {
169 - "dark" => Mode::Dark,
170 - "high-contrast" => Mode::HighContrast,
171 - _ => Mode::Light,
172 - };
99 + let line = get("line.border")?;
173 100
174 101 Ok(Self {
175 - mode,
176 -
177 - surface_page: rgb(surface_page),
178 - surface_raised: rgb(get("surface.raised")?),
179 - surface_sunken: rgb(get("surface.sunken")?),
180 - surface_overlay: rgb(get("surface.overlay")?),
181 -
182 - // Optional where the others are required, because it is derived
183 - // rather than authored: makeover emits it only when the theme gave
184 - // it both a raised surface and a content color to read the direction
185 - // off. Demanding it would reject a theme that is otherwise complete.
186 - surface_well: resolved
187 - .hex("surface-well")
188 - .and_then(Rgb::from_hex)
189 - .map(rgb),
190 -
191 - content_primary: rgb(content_primary),
192 - content_secondary: rgb(get("content.secondary")?),
193 - content_muted: rgb(get("content.muted")?),
194 -
195 - action_primary: rgb(get("action.primary")?),
196 -
197 - status_danger: rgb(get("status.danger")?),
198 - status_success: rgb(get("status.success")?),
199 - status_warning: rgb(get("status.warning")?),
200 - status_info: rgb(get("status.info")?),
201 -
202 - line_border: rgb(line_border),
203 - border_subtle: rgb(border_subtle),
204 - border_strong: rgb(border_strong),
205 -
206 - bevel_light: rgb(intent("bevel-light")?),
207 - bevel_dark: rgb(intent("bevel-dark")?),
208 -
209 - category: [
210 - rgb(get("category.one")?),
211 - rgb(get("category.two")?),
212 - rgb(get("category.three")?),
213 - rgb(get("category.four")?),
214 - rgb(get("category.five")?),
215 - rgb(get("category.six")?),
216 - ],
102 + makeover: makeover_theme,
103 + border_subtle: rgb(border_subtle(line, get("surface.page")?)),
104 + border_strong: rgb(border_strong(line, get("content.primary")?)),
217 105 })
218 106 }
107 +
108 + /// This theme as the terminal can actually draw it.
109 + ///
110 + /// The family half is [`makeover_tui::Theme::for_terminal`], which owns the
111 + /// rules: quantised against the page where a colour must stay legible
112 + /// against it, plainly where it is measured against the surface it sits on.
113 + /// Alloy's two tokens are borders seen against the page, so they take the
114 + /// against-the-page path, through the same [`Quantize`] rather than a second
115 + /// implementation of it.
116 + ///
117 + /// Quantised against the page *as authored*, not as quantised. The family
118 + /// half does the same, and matching matters: measuring against an already
119 + /// indexed page would answer a different question than the one the rule asks.
120 + #[must_use]
121 + pub fn for_terminal(self, fidelity: Fidelity) -> Self {
122 + let Some(q) = Quantize::for_fidelity(fidelity) else {
123 + return self;
124 + };
125 + let page = self.makeover.surface_page;
126 +
127 + Self {
128 + makeover: self.makeover.for_terminal(fidelity),
129 + border_subtle: q.against(self.border_subtle, page),
130 + border_strong: q.against(self.border_strong, page),
131 + }
132 + }
133 +
134 + /// This theme as `makeover-tui`'s renderer wants it.
135 + ///
136 + /// Delegated whole: neither of Alloy's tokens is a surface or a bevel edge,
137 + /// so the renderer's palette is entirely the family's.
138 + ///
139 + /// `fidelity` has to be the same value passed to [`Theme::for_terminal`].
140 + /// The renderer takes its colours already quantised and cannot recover the
141 + /// depth from them afterwards, so it is told; telling it something else is
142 + /// how a frame ends up drawing a glyph fallback over colours that did not
143 + /// need one, or skipping it over colours that did.
144 + #[must_use]
145 + pub fn palette(&self, fidelity: Fidelity) -> Palette {
146 + self.makeover.palette(fidelity)
147 + }
219 148 }
220 149
221 150 fn rgb(c: Rgb) -> Color {
@@ -235,159 +164,49 @@
235 164 /// a running process in any way that matters, and a bevel is drawn many times a
236 165 /// frame, so asking once is both cheaper and more consistent than asking per
237 166 /// render.
238 - ///
239 - /// Replaces `detect_color_depth`, which answered the same question in Alloy's own
240 - /// vocabulary.
241 167 #[must_use]
242 168 pub fn fidelity() -> Fidelity {
243 169 static DETECTED: OnceLock<Fidelity> = OnceLock::new();
244 170 *DETECTED.get_or_init(Fidelity::detect)
245 171 }
246 172
247 - /// The palette to quantize into, and what to add to an index in it to get the
248 - /// number the terminal wants.
173 + /// A theme with distinct, easily-named colours, for tests that assert on which
174 + /// token reached which cell.
249 175 ///
250 - /// 256 resolves to makeover's fixed region rather than the whole table: the low
251 - /// sixteen are repaintable in every emulator, so a match landing there is a
252 - /// match against a color the user may have moved out from under it.
253 - fn palette_for(fidelity: Fidelity) -> Option<(&'static [makeover::Rgb], usize)> {
254 - match fidelity {
255 - Fidelity::TrueColor => None,
256 - Fidelity::Ansi256 => Some((makeover::ANSI_240, makeover::ANSI_240_OFFSET)),
257 - Fidelity::Ansi16 => Some((&makeover::ANSI_16, 0)),
258 - }
259 - }
176 + /// Built by loading a real bundled theme and overwriting every field, because
177 + /// [`makeover_tui::Theme`] is `#[non_exhaustive]`: a crate outside it cannot
178 + /// write the literal, though it may mutate the fields of one it owns. Was three
179 + /// identical literals in `bevel`, `help` and `connector`, differing only in
180 + /// `mode`.
181 + #[cfg(test)]
182 + pub(crate) fn test_theme(mode: Mode) -> Theme {
183 + let dir = makeover::bundled_themes_dir().expect("makeover ships a themes dir");
184 + let colors = makeover::load_theme(&[(dir, false)], "goingson").expect("bundled theme loads");
185 + let mut m = makeover_tui::Theme::from_theme(&colors).expect("bundled theme resolves");
260 186
261 - /// The palette entry for `c`, as an index the terminal will not reinterpret.
262 - fn indexed(c: Color, palette: &[Rgb], offset: usize) -> Color {
263 - match c {
264 - Color::Rgb(r, g, b) => {
265 - Color::Indexed((makeover::quantize(Rgb { r, g, b }, palette) + offset) as u8)
266 - }
267 - other => other,
268 - }
269 - }
187 + m.mode = mode;
188 + m.surface_page = Color::Rgb(0, 0, 0);
189 + m.surface_raised = Color::Rgb(1, 1, 1);
190 + m.surface_sunken = Color::Rgb(2, 2, 2);
191 + m.surface_overlay = Color::Rgb(3, 3, 3);
192 + m.surface_well = Some(Color::Rgb(9, 9, 9));
193 + m.content_primary = Color::Rgb(4, 4, 4);
194 + m.content_secondary = Color::Rgb(5, 5, 5);
195 + m.content_muted = Color::Rgb(6, 6, 6);
196 + m.action_primary = Color::Rgb(7, 7, 7);
197 + m.status_danger = Color::Rgb(8, 8, 8);
198 + m.status_success = Color::Rgb(9, 9, 9);
199 + m.status_warning = Color::Rgb(10, 10, 10);
200 + m.status_info = Color::Rgb(11, 11, 11);
201 + m.line_border = Color::Rgb(12, 12, 12);
202 + m.bevel_light = Color::Rgb(16, 16, 16);
203 + m.bevel_dark = Color::Rgb(17, 17, 17);
204 + m.category = [Color::Rgb(15, 15, 15); 6];
270 205
271 - /// As [`indexed`], but guaranteed to stay legible against `on`.
272 - ///
273 - /// Only for a color whose job is to be told apart from a known background. It
274 - /// answers "nearest entry that still contrasts with `on`" and has no notion of
275 - /// which side of `on` the answer should fall, so a pair of colors that must also
276 - /// stay apart from *each other* is the one thing it must not be used for: both
277 - /// are pushed onto the same contrasting entry. That is why the bevel edges go
278 - /// through [`indexed`].
279 - fn indexed_against(c: Color, on: Color, palette: &[Rgb], offset: usize) -> Color {
280 - match (c, on) {
281 - (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed(
282 - (makeover::quantize_against(
283 - Rgb { r, g, b },
284 - Rgb {
285 - r: br,
286 - g: bg,
287 - b: bb,
288 - },
289 - palette,
290 - ) + offset) as u8,
291 - ),
292 - _ => indexed(c, palette, offset),
293 - }
294 - }
295 -
296 - impl Theme {
297 - /// This theme as the terminal can actually draw it.
298 - ///
299 - /// At [`Fidelity::TrueColor`] the theme is returned untouched. Otherwise every
300 - /// color becomes a palette index, which is the point: left as 24-bit, the
301 - /// terminal approximates them itself, and its approximation collapses tones
302 - /// that the theme keeps apart. Alloy's console lost its frame that way,
303 - /// drawing a border in a color the Linux console could not distinguish from
304 - /// the page behind it.
305 - ///
306 - /// Anything that has to be seen against the page is quantized against it
307 - /// rather than on its own, so a border stays a border and text stays
308 - /// readable. The surfaces themselves are quantized plainly: they are what
309 - /// the others are measured against.
310 - ///
311 - /// The bevel edges are quantized plainly too, for a different reason. They
312 - /// are measured against the raised surface they surround rather than against
313 - /// the page, and running them through [`indexed_against`] would push both
314 - /// onto the same entry and invert the bevel on one side. At
315 - /// [`Fidelity::Ansi16`] the palette cannot hold the pair at all and one
316 - /// edge lands back on its face, which is a property of sixteen colors rather
317 - /// than something this can fix. A caller drawing there does not have to
318 - /// handle that itself: [`Theme::palette`] carries the fidelity through to
319 - /// `makeover-tui`, which answers it with glyphs instead of tones.
320 - #[must_use]
321 - pub fn for_terminal(self, fidelity: Fidelity) -> Theme {
322 - let Some((palette, offset)) = palette_for(fidelity) else {
323 - return self;
324 - };
325 -
326 - let plain = |c: Color| indexed(c, palette, offset);
327 - let on_page = |c: Color| indexed_against(c, self.surface_page, palette, offset);
328 -
329 - Theme {
330 - mode: self.mode,
331 -
332 - surface_page: plain(self.surface_page),
333 - surface_raised: plain(self.surface_raised),
334 - surface_sunken: plain(self.surface_sunken),
335 - surface_overlay: plain(self.surface_overlay),
336 - // Plainly, like the other surfaces and for the same reason as the
337 - // bevel pair: a well is measured against the raised face it is cut
338 - // into, not against the page, so quantizing it against the page
339 - // would push it toward contrast it is not supposed to have.
340 - surface_well: self.surface_well.map(plain),
341 -
342 - content_primary: on_page(self.content_primary),
343 - content_secondary: on_page(self.content_secondary),
344 - content_muted: on_page(self.content_muted),
345 -
346 - action_primary: on_page(self.action_primary),
347 -
348 - status_danger: on_page(self.status_danger),
349 - status_success: on_page(self.status_success),
350 - status_warning: on_page(self.status_warning),
351 - status_info: on_page(self.status_info),
352 -
353 - line_border: on_page(self.line_border),
354 - border_subtle: on_page(self.border_subtle),
355 - border_strong: on_page(self.border_strong),
356 -
357 - bevel_light: plain(self.bevel_light),
358 - bevel_dark: plain(self.bevel_dark),
359 -
360 - category: self.category.map(on_page),
361 - }
362 - }
363 - }
364 -
365 - impl Theme {
366 - /// This theme as `makeover-tui`'s renderer wants it.
367 - ///
368 - /// The one place a [`Palette`] is assembled. Every widget that draws through
369 - /// the family renderer asks here rather than filling the struct itself,
370 - /// because two of the fields are decisions rather than lookups — which token
371 - /// serves as the well, and whether `fidelity` matches what the colors were
372 - /// actually quantized to — and a per-widget copy is a per-widget chance to
373 - /// answer them differently.
374 - ///
375 - /// `fidelity` has to be the same value passed to [`Theme::for_terminal`].
376 - /// The renderer takes its colors already quantized and cannot recover the
377 - /// depth from them afterwards, so it is told; telling it something else is
378 - /// how a frame ends up drawing a glyph fallback over colors that did not
379 - /// need one, or skipping the fallback over colors that did.
380 - #[must_use]
Lines truncated
M src/widgets.rs +61 -72
@@ -70,8 +70,8 @@
70 70 .padding(crate::geometry::padding(crate::geometry::Gap::Group))
71 71 .style(
72 72 Style::default()
73 - .bg(self.theme.surface_page)
74 - .fg(self.theme.content_primary),
73 + .bg(self.theme.makeover.surface_page)
74 + .fg(self.theme.makeover.content_primary),
75 75 )
76 76 }
77 77 }
@@ -99,10 +99,10 @@
99 99
100 100 pub fn color(self, theme: &Theme) -> Color {
101 101 match self {
102 - Severity::Info => theme.status_info,
103 - Severity::Healthy => theme.status_success,
104 - Severity::Warn => theme.status_warning,
105 - Severity::Error => theme.status_danger,
102 + Severity::Info => theme.makeover.status_info,
103 + Severity::Healthy => theme.makeover.status_success,
104 + Severity::Warn => theme.makeover.status_warning,
105 + Severity::Error => theme.makeover.status_danger,
106 106 }
107 107 }
108 108
@@ -155,7 +155,7 @@
155 155
156 156 impl Widget for AlloyStatusBar<'_> {
157 157 fn render(self, area: Rect, buf: &mut Buffer) {
158 - let base = Style::default().bg(self.theme.surface_sunken);
158 + let base = Style::default().bg(self.theme.makeover.surface_sunken);
159 159 Paragraph::new("").style(base).render(area, buf);
160 160
161 161 let mut spans: Vec<Span> = Vec::with_capacity(self.hints.len() * 3);
@@ -313,7 +313,7 @@
313 313 ///
314 314 /// Selection reads as brackets plus weight rather than color. Per
315 315 /// DESIGN-LANGUAGE.md color stays off chrome, and per the same reasoning as
316 - /// [`MARKER`](crate::MARKER) being a plain triangle, a bracket survives a
316 + /// [`MARKER`] being a plain triangle, a bracket survives a
317 317 /// console with no theme and no patched font — the TTY before the session
318 318 /// starts, `alloy` over SSH.
319 319 pub struct AlloyTabs<'a> {
@@ -369,7 +369,7 @@
369 369
370 370 let row = Rect { height: 1, ..area };
371 371 Paragraph::new(Line::from(spans))
372 - .style(Style::default().bg(self.theme.surface_page))
372 + .style(Style::default().bg(self.theme.makeover.surface_page))
373 373 .render(row, buf);
374 374 }
375 375 }
@@ -453,7 +453,7 @@
453 453 // the bevel is unchanged, because a primary button is lit like every
454 454 // other and only filled differently.
455 455 Paragraph::new("")
456 - .style(Style::default().bg(self.theme.content_primary))
456 + .style(Style::default().bg(self.theme.makeover.content_primary))
457 457 .render(area, buf);
458 458 let elevation = if self.pressed {
459 459 Elevation::Sunken
@@ -481,11 +481,11 @@
481 481 }
482 482
483 483 let fg = if self.disabled {
484 - self.theme.content_muted
484 + self.theme.makeover.content_muted
485 485 } else if self.primary {
486 - self.theme.surface_raised
486 + self.theme.makeover.surface_raised
487 487 } else {
488 - self.theme.content_primary
488 + self.theme.makeover.content_primary
489 489 };
490 490
491 491 // The label goes on the middle row, inside the edge. On a button too
@@ -557,8 +557,8 @@
557 557 }
558 558
559 559 let base = Style::default()
560 - .bg(self.theme.surface_overlay)
561 - .fg(self.theme.content_primary);
560 + .bg(self.theme.makeover.surface_overlay)
561 + .fg(self.theme.makeover.content_primary);
562 562
563 563 let block = Block::default()
564 564 .borders(Borders::ALL)
@@ -583,10 +583,16 @@
583 583 // one failure this widget must not have.
584 584 let keys = Line::from(vec![
585 585 text::action(self.theme, "Enter"),
586 - Span::styled(" confirm", Style::default().fg(self.theme.content_muted)),
586 + Span::styled(
587 + " confirm",
588 + Style::default().fg(self.theme.makeover.content_muted),
589 + ),
587 590 Span::raw(" "),
588 591 text::action(self.theme, "Esc"),
589 - Span::styled(" cancel", Style::default().fg(self.theme.content_muted)),
592 + Span::styled(
593 + " cancel",
594 + Style::default().fg(self.theme.makeover.content_muted),
595 + ),
590 596 ]);
591 597
592 598 let message_height = inner.height.saturating_sub(1);
@@ -681,7 +687,7 @@
681 687 .collect();
682 688
683 689 Paragraph::new(lines)
684 - .style(Style::default().bg(self.theme.surface_page))
690 + .style(Style::default().bg(self.theme.makeover.surface_page))
685 691 .render(inner, buf);
686 692 }
687 693 }
@@ -810,7 +816,7 @@
810 816 }
811 817
812 818 let style = if self.unset {
813 - base.fg(self.theme.content_muted)
819 + base.fg(self.theme.makeover.content_muted)
814 820 } else {
815 821 base
816 822 };
@@ -856,7 +862,7 @@
856 862 Shadow::medium_shade().style(
857 863 Style::default()
858 864 .fg(theme.border_strong)
859 - .bg(theme.surface_page),
865 + .bg(theme.makeover.surface_page),
860 866 )
861 867 }
862 868
@@ -883,8 +889,8 @@
883 889 fn caret_spans<'s>(theme: &Theme, buffer: &TextField, base: Style) -> Vec<Span<'s>> {
884 890 let (before, under, after) = buffer.split();
885 891 let caret = Style::default()
886 - .bg(theme.content_primary)
887 - .fg(theme.surface_page);
892 + .bg(theme.makeover.content_primary)
893 + .fg(theme.makeover.surface_page);
888 894 vec![
889 895 Span::styled(before.to_string(), base),
890 896 // A caret past the end of the line has no character to sit on, so it
@@ -916,7 +922,7 @@
916 922 let width = self.label_width.max(self.label.chars().count());
917 923 spans.push(Span::styled(
918 924 format!("{:width$} ", self.label, width = width),
919 - base.fg(self.theme.content_secondary),
925 + base.fg(self.theme.makeover.content_secondary),
920 926 ));
921 927 spans.extend(self.value_spans(base));
922 928
@@ -1057,7 +1063,7 @@
1057 1063 Span::styled(format!("{fold} "), style),
1058 1064 Span::styled(
1059 1065 label,
1060 - style.patch(Style::default().fg(self.theme.content_primary)),
1066 + style.patch(Style::default().fg(self.theme.makeover.content_primary)),
1061 1067 ),
1062 1068 ]))
1063 1069 .style(style)
@@ -1077,7 +1083,7 @@
1077 1083 height: 1,
1078 1084 ..area
1079 1085 };
1080 - let base = Style::default().bg(self.theme.surface_page);
1086 + let base = Style::default().bg(self.theme.makeover.surface_page);
1081 1087 Paragraph::new(footer.unwrap_or_default())
1082 1088 .style(base)
1083 1089 .render(line_area, buf);
@@ -1204,8 +1210,8 @@
1204 1210 }
1205 1211
1206 1212 let base = Style::default()
1207 - .bg(self.theme.surface_overlay)
1208 - .fg(self.theme.content_primary);
1213 + .bg(self.theme.makeover.surface_overlay)
1214 + .fg(self.theme.makeover.content_primary);
1209 1215 let block = Block::default()
1210 1216 .borders(Borders::ALL)
1211 1217 .border_style(Style::default().fg(self.theme.border_strong))
@@ -1228,10 +1234,10 @@
1228 1234 // keys move with content length is one the user can lose.
1229 1235 let (before, under, after) = self.filter.split();
1230 1236 let caret = Style::default()
1231 - .bg(self.theme.content_primary)
1232 - .fg(self.theme.surface_overlay);
1237 + .bg(self.theme.makeover.content_primary)
1238 + .fg(self.theme.makeover.surface_overlay);
1233 1239 Paragraph::new(Line::from(vec![
1234 - Span::styled("/ ", base.fg(self.theme.content_muted)),
1240 + Span::styled("/ ", base.fg(self.theme.makeover.content_muted)),
1235 1241 Span::styled(before.to_string(), base),
1236 1242 Span::styled(under.map_or(" ".to_string(), String::from), caret),
1237 1243 Span::styled(after.to_string(), base),
@@ -1241,10 +1247,16 @@
1241 1247
1242 1248 let keys = Line::from(vec![
1243 1249 text::action(self.theme, "enter"),
1244 - Span::styled(" select", Style::default().fg(self.theme.content_muted)),
1250 + Span::styled(
1251 + " select",
1252 + Style::default().fg(self.theme.makeover.content_muted),
1253 + ),
1245 1254 Span::raw(" "),
1246 1255 text::action(self.theme, "esc"),
1247 - Span::styled(" cancel", Style::default().fg(self.theme.content_muted)),
1256 + Span::styled(
1257 + " cancel",
1258 + Style::default().fg(self.theme.makeover.content_muted),
1259 + ),
1248 1260 ]);
1249 1261 if inner.height > 1 {
1250 1262 Paragraph::new(keys).style(base).render(
@@ -1269,7 +1281,7 @@
1269 1281 if self.rows.is_empty() {
1270 1282 Paragraph::new(Line::from(Span::styled(
1271 1283 self.empty,
1272 - base.fg(self.theme.content_muted),
1284 + base.fg(self.theme.makeover.content_muted),
1273 1285 )))
1274 1286 .style(base)
1275 1287 .render(list_area, buf);
@@ -1296,7 +1308,7 @@
1296 1308 if let Some(description) = row.description {
1297 1309 spans.push(Span::styled(
1298 1310 format!("{DESCRIPTION_GAP}{description}"),
1299 - Style::default().fg(self.theme.content_muted),
1311 + Style::default().fg(self.theme.makeover.content_muted),
1300 1312 ));
1301 1313 }
1302 1314 Line::from(spans)
@@ -1397,7 +1409,7 @@
1397 1409 }
1398 1410
1399 1411 Paragraph::new(lines)
1400 - .style(Style::default().bg(self.theme.surface_page))
1412 + .style(Style::default().bg(self.theme.makeover.surface_page))
1401 1413 .render(area, buf);
1402 1414 }
1403 1415 }
@@ -1408,28 +1420,7 @@
1408 1420 use ratatui::style::Color;
1409 1421
1410 1422 fn theme() -> Theme {
1411 - Theme {
1412 - mode: crate::theme::Mode::Dark,
1413 - surface_page: Color::Rgb(0, 0, 0),
1414 - surface_raised: Color::Rgb(1, 1, 1),
1415 - surface_sunken: Color::Rgb(2, 2, 2),
1416 - surface_overlay: Color::Rgb(3, 3, 3),
1417 - surface_well: Some(Color::Rgb(9, 9, 9)),
1418 - content_primary: Color::Rgb(4, 4, 4),
1419 - content_secondary: Color::Rgb(5, 5, 5),
1420 - content_muted: Color::Rgb(6, 6, 6),
1421 - action_primary: Color::Rgb(7, 7, 7),
1422 - status_danger: Color::Rgb(8, 8, 8),
1423 - status_success: Color::Rgb(9, 9, 9),
1424 - status_warning: Color::Rgb(10, 10, 10),
1425 - status_info: Color::Rgb(11, 11, 11),
1426 - line_border: Color::Rgb(12, 12, 12),
1427 - border_subtle: Color::Rgb(13, 13, 13),
1428 - border_strong: Color::Rgb(14, 14, 14),
1429 - bevel_light: Color::Rgb(16, 16, 16),
1430 - bevel_dark: Color::Rgb(17, 17, 17),
1431 - category: [Color::Rgb(15, 15, 15); 6],
1432 - }
1423 + crate::theme::test_theme(crate::theme::Mode::Dark)
1433 1424 }
1434 1425
1435 1426 fn list_of(n: usize, selected: Option<usize>) -> AlloyList<'static> {
@@ -2033,15 +2024,15 @@
2033 2024 let (down, _) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3);
2034 2025
2035 2026 assert_eq!(rows(&up, area), rows(&down, area));
2036 - assert_eq!(up[(0u16, 0u16)].fg, theme.bevel_light);
2037 - assert_eq!(down[(0u16, 0u16)].fg, theme.bevel_dark);
2038 - assert_eq!(up[(3u16, 1u16)].bg, theme.surface_raised);
2027 + assert_eq!(up[(0u16, 0u16)].fg, theme.makeover.bevel_light);
2028 + assert_eq!(down[(0u16, 0u16)].fg, theme.makeover.bevel_dark);
2029 + assert_eq!(up[(3u16, 1u16)].bg, theme.makeover.surface_raised);
2039 2030 // The well, not `surface_sunken`. A theme may author sunken darker than
2040 2031 // raised while a well always inverts away from the text, so the two are
2041 2032 // only interchangeable on themes where the substitution happens not to
2042 2033 // bite. Pinned as the well so it stays that way.
2043 - assert_eq!(down[(3u16, 1u16)].bg, theme.surface_well.unwrap());
2044 - assert_ne!(down[(3u16, 1u16)].bg, theme.surface_sunken);
2034 + assert_eq!(down[(3u16, 1u16)].bg, theme.makeover.surface_well.unwrap());
2035 + assert_ne!(down[(3u16, 1u16)].bg, theme.makeover.surface_sunken);
2045 2036 }
2046 2037
2047 2038 // A theme that gave makeover nothing to derive a well from still has to
@@ -2050,13 +2041,11 @@
2050 2041 // than substituting some other surface.
2051 2042 #[test]
2052 2043 fn a_button_pressed_on_a_theme_with_no_well_keeps_its_inverted_edge() {
2053 - let theme = Theme {
2054 - surface_well: None,
2055 - ..theme()
2056 - };
2044 + let mut theme = theme();
2045 + theme.makeover.surface_well = None;
2057 2046 let (down, area) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3);
2058 2047 assert_eq!(rows(&down, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]);
2059 - assert_eq!(down[(0u16, 0u16)].fg, theme.bevel_dark);
2048 + assert_eq!(down[(0u16, 0u16)].fg, theme.makeover.bevel_dark);
2060 2049 }
2061 2050
2062 2051 // Dimmed and still there, so the layout keeps teaching itself.
@@ -2065,17 +2054,17 @@
2065 2054 let theme = theme();
2066 2055 let (buf, area) = render_button(AlloyButton::new(&theme, "OK").disabled(true), 8, 3);
2067 2056 assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]);
2068 - assert_eq!(buf[(0u16, 0u16)].fg, theme.bevel_light);
2069 - assert_eq!(buf[(3u16, 1u16)].fg, theme.content_muted);
2057 + assert_eq!(buf[(0u16, 0u16)].fg, theme.makeover.bevel_light);
2058 + assert_eq!(buf[(3u16, 1u16)].fg, theme.makeover.content_muted);
2070 2059 }
2071 2060
2072 2061 #[test]
2073 2062 fn a_primary_button_inverts_polarity_without_touching_the_bevel() {
2074 2063 let theme = theme();
2075 2064 let (buf, _) = render_button(AlloyButton::new(&theme, "OK").primary(true), 8, 3);
2076 - assert_eq!(buf[(3u16, 1u16)].bg, theme.content_primary);
2077 - assert_eq!(buf[(3u16, 1u16)].fg, theme.surface_raised);
2078 - assert_eq!(buf[(0u16, 0u16)].fg, theme.bevel_light);
2065 + assert_eq!(buf[(3u16, 1u16)].bg, theme.makeover.content_primary);
2066 + assert_eq!(buf[(3u16, 1u16)].fg, theme.makeover.surface_raised);
2067 + assert_eq!(buf[(0u16, 0u16)].fg, theme.makeover.bevel_light);
2079 2068 }
2080 2069
2081 2070 // ---- floating surfaces ----