Skip to main content

max / alloy_tui

6.3 KB · 197 lines History Blame Raw
1 //! Theme palette: makeover intents resolved into ratatui `Color`s, plus
2 //! the two Alloy-derived border tokens.
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:
7 //!
8 //! - `border-subtle = mix(line.border, surface.page, 60%)` decorative divider
9 //! - `border-strong = mix(line.border, content.primary, 65%)` focus / selection
10 //!
11 //! Mix is in linear sRGB, matching TOKENS.md's worked audit math.
12 //!
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.
17
18 use ratatui::style::Color;
19 use makeover::{Rgb, ThemeColors};
20
21 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22 pub enum Mode {
23 Light,
24 Dark,
25 HighContrast,
26 }
27
28 #[derive(Debug, Clone, Copy)]
29 pub struct Theme {
30 pub mode: Mode,
31
32 pub surface_page: Color,
33 pub surface_raised: Color,
34 pub surface_sunken: Color,
35 pub surface_overlay: Color,
36
37 pub content_primary: Color,
38 pub content_secondary: Color,
39 pub content_muted: Color,
40
41 pub action_primary: Color,
42
43 pub status_danger: Color,
44 pub status_success: Color,
45 pub status_warning: Color,
46 pub status_info: Color,
47
48 pub line_border: Color,
49 pub border_subtle: Color,
50 pub border_strong: Color,
51
52 pub category: [Color; 6],
53 }
54
55 #[derive(Debug, Clone)]
56 pub enum ThemeError {
57 MissingKey(&'static str),
58 InvalidHex { key: &'static str, value: String },
59 }
60
61 impl std::fmt::Display for ThemeError {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 ThemeError::MissingKey(k) => write!(f, "theme missing required key `{k}`"),
65 ThemeError::InvalidHex { key, value } => {
66 write!(f, "theme key `{key}` has invalid hex value `{value}`")
67 }
68 }
69 }
70 }
71
72 impl std::error::Error for ThemeError {}
73
74 impl Theme {
75 /// Resolve a loaded makeover `ThemeColors` into an Alloy `Theme`.
76 /// Requires every intent Alloy renders — a malformed or partial theme is
77 /// rejected explicitly rather than silently rendering with defaults.
78 pub fn from_theme(theme: &ThemeColors) -> Result<Self, ThemeError> {
79 let get = |key: &'static str| -> Result<Rgb, ThemeError> {
80 let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?;
81 Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
82 key,
83 value: hex.clone(),
84 })
85 };
86
87 let surface_page = get("surface.page")?;
88 let content_primary = get("content.primary")?;
89 let line_border = get("line.border")?;
90
91 let border_subtle = mix_linear_srgb(line_border, surface_page, 0.60);
92 let border_strong = mix_linear_srgb(line_border, content_primary, 0.65);
93
94 let mode = match theme.meta.variant.as_str() {
95 "dark" => Mode::Dark,
96 "high-contrast" => Mode::HighContrast,
97 _ => Mode::Light,
98 };
99
100 Ok(Self {
101 mode,
102
103 surface_page: rgb(surface_page),
104 surface_raised: rgb(get("surface.raised")?),
105 surface_sunken: rgb(get("surface.sunken")?),
106 surface_overlay: rgb(get("surface.overlay")?),
107
108 content_primary: rgb(content_primary),
109 content_secondary: rgb(get("content.secondary")?),
110 content_muted: rgb(get("content.muted")?),
111
112 action_primary: rgb(get("action.primary")?),
113
114 status_danger: rgb(get("status.danger")?),
115 status_success: rgb(get("status.success")?),
116 status_warning: rgb(get("status.warning")?),
117 status_info: rgb(get("status.info")?),
118
119 line_border: rgb(line_border),
120 border_subtle: rgb(border_subtle),
121 border_strong: rgb(border_strong),
122
123 category: [
124 rgb(get("category.one")?),
125 rgb(get("category.two")?),
126 rgb(get("category.three")?),
127 rgb(get("category.four")?),
128 rgb(get("category.five")?),
129 rgb(get("category.six")?),
130 ],
131 })
132 }
133 }
134
135 fn rgb(c: Rgb) -> Color {
136 Color::Rgb(c.r, c.g, c.b)
137 }
138
139 // Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are
140 // gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually
141 // less uniform than OKLab but keeps the derived hex reproducible against the
142 // contrast tables in TOKENS.md.
143 fn mix_linear_srgb(a: Rgb, b: Rgb, t: f32) -> Rgb {
144 let al = srgb_to_linear(a);
145 let bl = srgb_to_linear(b);
146 let m = (
147 al.0 + (bl.0 - al.0) * t,
148 al.1 + (bl.1 - al.1) * t,
149 al.2 + (bl.2 - al.2) * t,
150 );
151 linear_to_srgb(m)
152 }
153
154 fn srgb_to_linear(c: Rgb) -> (f32, f32, f32) {
155 (channel_to_linear(c.r), channel_to_linear(c.g), channel_to_linear(c.b))
156 }
157
158 fn linear_to_srgb(c: (f32, f32, f32)) -> Rgb {
159 Rgb {
160 r: channel_to_srgb(c.0),
161 g: channel_to_srgb(c.1),
162 b: channel_to_srgb(c.2),
163 }
164 }
165
166 fn channel_to_linear(c: u8) -> f32 {
167 let c = c as f32 / 255.0;
168 if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
169 }
170
171 fn channel_to_srgb(c: f32) -> u8 {
172 let v = if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 };
173 (v * 255.0).round().clamp(0.0, 255.0) as u8
174 }
175
176 #[cfg(test)]
177 mod tests {
178 use super::*;
179
180 // TOKENS.md line 61 anchors the derivation math against Akari Dawn:
181 // line.border = #cabeae, content.primary = #1a1816, mix 65% toward primary
182 // must produce #7f786d (the value the contrast-audit table is calibrated on).
183 // If this test fails, the audit table in TOKENS.md is stale, not the code.
184 #[test]
185 fn akari_dawn_border_strong_matches_tokens_md() {
186 let border = Rgb::from_hex("#cabeae").unwrap();
187 let primary = Rgb::from_hex("#1a1816").unwrap();
188 let got = mix_linear_srgb(border, primary, 0.65);
189 assert_eq!(
190 (got.r, got.g, got.b),
191 (0x7f, 0x78, 0x6d),
192 "border-strong derivation drifted; got #{:02x}{:02x}{:02x}, expected #7f786d",
193 got.r, got.g, got.b
194 );
195 }
196 }
197