|
1 |
+ |
//! Theme palette: theme-common intents resolved into ratatui `Color`s, plus
|
|
2 |
+ |
//! the two Alloy-derived border tokens.
|
|
3 |
+ |
//!
|
|
4 |
+ |
//! Per docs/TOKENS.md, Alloy consumes theme-common `.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 |
+ |
//! `theme_common::load_theme` + `Theme::from_theme`) and thread it through.
|
|
17 |
+ |
|
|
18 |
+ |
use ratatui::style::Color;
|
|
19 |
+ |
use theme_common::{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 theme-common `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 |
+ |
}
|