lumen_core/palette.rs
1//! Design-token / named-color [`Palette`].
2//!
3//! Mirrors the libadwaita "named colors" table
4//! (<https://gnome.pages.gitlab.gnome.org/libadwaita/doc/main/named-colors.html>).
5//! [`Palette::adwaita_light`] and [`Palette::adwaita_dark`] ship as a
6//! built-in light/dark theme; values come from `defaults-light.css` /
7//! `defaults-dark.css` in the libadwaita sources.
8//!
9//! [`Palette::root_vars`] turns a palette into the same
10//! `HashMap<String, String>` shape the CSS `var()` resolver in `lumen-ir`
11//! merges custom properties from. The runtime's app loader (`lumen-runtime`,
12//! which depends on this crate, not the other way around) folds it in once
13//! at load time as the lowest-precedence `:root` layer - beneath the
14//! always-on UA baseline, beneath any opted-in skin, and beneath the app's
15//! own `main.css` - so `var(--accent-color)` and friends resolve wherever a
16//! `--lumen-*` token does, and any layer above can still override a name it
17//! redeclares.
18//!
19//! This is a load-time bake, the same way a skin's own `:root` block is:
20//! nothing here re-runs the cascade when a `Palette` value changes later.
21//! `Palette` derives [`Resource`] so a caller can still insert one and read
22//! it back (`palette.lookup("accent_color")`) for its own purposes, but the
23//! runtime does not insert one automatically and does not watch
24//! `Changed<Palette>` - the load-time bake works for both the from-source
25//! and the precompiled-artifact run path (the latter ships with no CSS
26//! parser at all to re-run), and a live re-resolve is a separate,
27//! not-yet-built feature.
28
29use std::collections::HashMap;
30use std::sync::Arc;
31
32use bevy_ecs::resource::Resource;
33
34use crate::components::Color;
35
36/// Named-color palette modeled on libadwaita's `@accent_color`,
37/// `@window_bg_color`, ... bus. Keys are stable Adwaita role names
38/// stored as `Arc<str>` so repeated lookups share one allocation.
39///
40/// Override individual roles with [`Self::with`] before feeding
41/// [`Self::root_vars`] into the loader, or build one from scratch with
42/// [`Self::new`], to change just `accent_color` without restating the
43/// whole table. See the module doc comment for what "feeding the loader"
44/// means today and what it does not yet do.
45#[derive(Resource, Clone, Debug, Default, PartialEq)]
46pub struct Palette {
47 /// Role-name -> [`Color`] map. Names follow libadwaita's
48 /// `@accent_color`, `@window_bg_color`, ... convention (no leading
49 /// `@`; the parser strips it before lookup).
50 pub colors: HashMap<Arc<str>, Color>,
51}
52
53impl Palette {
54 /// Empty palette. Useful as a base before [`Self::with`] chains.
55 pub fn new() -> Self {
56 Self {
57 colors: HashMap::new(),
58 }
59 }
60
61 /// Default light palette from libadwaita `defaults-light.css`.
62 ///
63 /// Values mirror libadwaita 1.5 defaults; alpha-bearing roles
64 /// (`@shade_color`, `@scrollbar_outline_color`, `@borders`) use
65 /// the `alpha(...)` channel libadwaita ships, baked at table-init
66 /// time. The Adwaita `@card_bg_color` is `alpha(@window_fg, 0.05)`;
67 /// here it is pre-resolved against the light `@window_fg_color`.
68 pub fn adwaita_light() -> Self {
69 let mut p = Self::new();
70 // Accent (Adwaita "blue 3" #3584e4).
71 p = p
72 .with("accent_color", (53, 132, 228))
73 .with("accent_bg_color", (53, 132, 228))
74 .with("accent_fg_color", "#ffffff")
75 // Destructive (Adwaita "red 3" #e01b24).
76 .with("destructive_color", (192, 28, 40))
77 .with("destructive_bg_color", (224, 27, 36))
78 .with("destructive_fg_color", "#ffffff")
79 // Success (Adwaita "green 4" #2ec27e -> label color 1d8348).
80 .with("success_color", (29, 153, 84))
81 .with("success_bg_color", (46, 194, 126))
82 .with("success_fg_color", "#ffffff")
83 // Warning (Adwaita "yellow 5" #e5a50a -> label 905400).
84 .with("warning_color", (144, 84, 0))
85 .with("warning_bg_color", (229, 165, 10))
86 .with("warning_fg_color", Color::rgba(0.0, 0.0, 0.0, 0.8))
87 // Error (Adwaita "red 4" #c01c28).
88 .with("error_color", (192, 28, 40))
89 .with("error_bg_color", (224, 27, 36))
90 .with("error_fg_color", "#ffffff")
91 // Window background / foreground.
92 .with("window_bg_color", "#fafafb")
93 .with("window_fg_color", Color::rgba(0.0, 0.0, 0.0, 0.8))
94 // View (text-bearing surfaces - entries, list rows).
95 .with("view_bg_color", "#ffffff")
96 .with("view_fg_color", Color::rgba(0.0, 0.0, 0.0, 0.8))
97 // Header bar.
98 .with("headerbar_bg_color", "#ebebed")
99 .with("headerbar_fg_color", Color::rgba(0.0, 0.0, 0.0, 0.8))
100 // Card (Adwaita: alpha(@window_fg_color, 0.05) on light).
101 .with("card_bg_color", Color::rgba(0.0, 0.0, 0.0, 0.05))
102 // Sidebar / popover surfaces.
103 .with("sidebar_bg_color", "#ebebed")
104 .with("popover_bg_color", "#ffffff")
105 // Shade overlay (alpha(black, 0.07) on light per Adwaita).
106 .with("shade_color", Color::rgba(0.0, 0.0, 0.0, 0.07))
107 // Scrollbar outline (alpha(white, 0.5) on light).
108 .with("scrollbar_outline_color", Color::rgba(1.0, 1.0, 1.0, 0.5))
109 // Border (alpha(@window_fg_color, 0.15) on light).
110 .with("borders", Color::rgba(0.0, 0.0, 0.0, 0.15));
111 p
112 }
113
114 /// Default dark palette from libadwaita `defaults-dark.css`.
115 pub fn adwaita_dark() -> Self {
116 let mut p = Self::new();
117 p = p
118 .with("accent_color", (120, 174, 237))
119 .with("accent_bg_color", (53, 132, 228))
120 .with("accent_fg_color", "#ffffff")
121 // Destructive (Adwaita dark label red).
122 .with("destructive_color", (255, 122, 128))
123 .with("destructive_bg_color", (192, 28, 40))
124 .with("destructive_fg_color", "#ffffff")
125 // Success (Adwaita dark label green #8ff0a4).
126 .with("success_color", (143, 240, 164))
127 .with("success_bg_color", (38, 162, 105))
128 .with("success_fg_color", "#ffffff")
129 // Warning (Adwaita dark label yellow #f8e45c).
130 .with("warning_color", (248, 228, 92))
131 .with("warning_bg_color", (205, 147, 9))
132 .with("warning_fg_color", Color::rgba(0.0, 0.0, 0.0, 0.8))
133 // Error (Adwaita dark label red).
134 .with("error_color", (255, 122, 128))
135 .with("error_bg_color", (192, 28, 40))
136 .with("error_fg_color", "#ffffff")
137 // Window background / foreground (Adwaita 1.5 dark base).
138 .with("window_bg_color", "#222226")
139 .with("window_fg_color", "#ffffff")
140 // View surfaces.
141 .with("view_bg_color", "#1d1d20")
142 .with("view_fg_color", "#ffffff")
143 // Header bar.
144 .with("headerbar_bg_color", "#2e2e32")
145 .with("headerbar_fg_color", "#ffffff")
146 // Card (Adwaita: alpha(white, 0.08) on dark).
147 .with("card_bg_color", Color::rgba(1.0, 1.0, 1.0, 0.08))
148 // Sidebar / popover.
149 .with("sidebar_bg_color", "#2e2e32")
150 .with("popover_bg_color", "#36363a")
151 // Shade overlay (alpha(black, 0.36) on dark).
152 .with("shade_color", Color::rgba(0.0, 0.0, 0.0, 0.36))
153 // Scrollbar outline (alpha(black, 0.5) on dark).
154 .with("scrollbar_outline_color", Color::rgba(0.0, 0.0, 0.0, 0.5))
155 // Border (alpha(white, 0.15) on dark).
156 .with("borders", Color::rgba(1.0, 1.0, 1.0, 0.15));
157 p
158 }
159
160 /// Register or override a named color and return `self` so calls
161 /// can chain. Names should match libadwaita's
162 /// `@accent_color` / `@window_bg_color` / ... convention.
163 ///
164 /// Accepts anything `Into<Color>` - hex literal (`"#rrggbb"`), RGB
165 /// tuple `(u8, u8, u8)`, or an explicit [`Color`].
166 pub fn with(mut self, name: impl Into<Arc<str>>, color: impl Into<Color>) -> Self {
167 self.colors.insert(name.into(), color.into());
168 self
169 }
170
171 /// Look up a named color. `name` matches with or without the
172 /// leading `@` so callers can pass `"accent_color"` (from a parser
173 /// that already stripped the sigil) or `"@accent_color"` (raw token)
174 /// interchangeably.
175 pub fn lookup(&self, name: &str) -> Option<Color> {
176 let key = name.strip_prefix('@').unwrap_or(name);
177 self.colors.get(key).copied()
178 }
179
180 /// Every named color as a CSS custom-property name (`--`-free, matching
181 /// the CSS `var()` resolver's key convention in `lumen-ir`) mapped to
182 /// its `#rrggbbaa` hex value: `"accent_color"` becomes `"accent-color"`
183 /// (hyphenated, the standard CSS custom-property spelling), values
184 /// unchanged otherwise. Lossless and mechanical - this does not rename
185 /// any role onto Lumen's own `--lumen-*` vocabulary, which is a
186 /// separate, opinionated mapping this method deliberately leaves
187 /// undecided.
188 ///
189 /// The runtime's app loader (`lumen-runtime`, which depends on this
190 /// crate - not the other way around, so it cannot be linked from here)
191 /// merges the result as the lowest-precedence `:root` layer: beneath
192 /// the always-on UA baseline, beneath any opted-in skin, and beneath
193 /// the app's own `main.css`. So `var(--accent-color)` resolves
194 /// wherever a `--lumen-*` token does, and a skin or app that
195 /// redeclares the same name still wins.
196 pub fn root_vars(&self) -> HashMap<String, String> {
197 self.colors
198 .iter()
199 .map(|(name, color)| (name.replace('_', "-"), to_hex8(*color)))
200 .collect()
201 }
202}
203
204/// Format a [`Color`] as an 8-digit `#rrggbbaa` hex string - the one shape
205/// the CSS color parser always accepts regardless of whether the source
206/// color was opaque, so [`Palette::root_vars`] never loses the alpha
207/// channel some Adwaita roles (`card_bg_color`, `shade_color`, ...)
208/// depend on.
209fn to_hex8(c: Color) -> String {
210 let [r, g, b, a] = c.to_rgba8();
211 format!("#{r:02x}{g:02x}{b:02x}{a:02x}")
212}
213
214// -- Color conversions (project memory: `From`/`Into` over `convert_x_to_y`) --
215
216impl From<(u8, u8, u8)> for Color {
217 fn from((r, g, b): (u8, u8, u8)) -> Self {
218 Self::rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0)
219 }
220}
221
222impl From<(u8, u8, u8, u8)> for Color {
223 fn from((r, g, b, a): (u8, u8, u8, u8)) -> Self {
224 Self::rgba(
225 r as f32 / 255.0,
226 g as f32 / 255.0,
227 b as f32 / 255.0,
228 a as f32 / 255.0,
229 )
230 }
231}
232
233impl From<&'static str> for Color {
234 /// Parses `#rgb`, `#rgba`, `#rrggbb`, or `#rrggbbaa` literals.
235 /// Falls back to [`Color::default`] on any parse error - this
236 /// impl is intended for the Adwaita defaults table where every
237 /// literal is known-good at compile time.
238 fn from(hex: &'static str) -> Self {
239 parse_hex(hex).unwrap_or_default()
240 }
241}
242
243fn parse_hex(s: &str) -> Option<Color> {
244 let h = s.strip_prefix('#').unwrap_or(s);
245 let bytes: Vec<u8> = match h.len() {
246 3 => h
247 .chars()
248 .map(|c| u8::from_str_radix(&format!("{c}{c}"), 16).ok())
249 .collect::<Option<Vec<_>>>()?,
250 4 => h
251 .chars()
252 .map(|c| u8::from_str_radix(&format!("{c}{c}"), 16).ok())
253 .collect::<Option<Vec<_>>>()?,
254 6 | 8 => (0..h.len())
255 .step_by(2)
256 .map(|i| u8::from_str_radix(&h[i..i + 2], 16).ok())
257 .collect::<Option<Vec<_>>>()?,
258 _ => return None,
259 };
260 Some(match *bytes.as_slice() {
261 [r, g, b] => (r, g, b).into(),
262 [r, g, b, a] => (r, g, b, a).into(),
263 _ => return None,
264 })
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn adwaita_light_carries_core_roles() {
273 let p = Palette::adwaita_light();
274 // Every named color the rewrite spec lists must be populated.
275 for role in [
276 "accent_color",
277 "accent_bg_color",
278 "accent_fg_color",
279 "destructive_color",
280 "destructive_bg_color",
281 "destructive_fg_color",
282 "success_color",
283 "warning_color",
284 "error_color",
285 "window_bg_color",
286 "window_fg_color",
287 "view_bg_color",
288 "view_fg_color",
289 "headerbar_bg_color",
290 "card_bg_color",
291 "sidebar_bg_color",
292 "popover_bg_color",
293 "shade_color",
294 "scrollbar_outline_color",
295 "borders",
296 ] {
297 assert!(
298 p.lookup(role).is_some(),
299 "light palette missing role {role}"
300 );
301 }
302 }
303
304 #[test]
305 fn adwaita_dark_carries_core_roles() {
306 let p = Palette::adwaita_dark();
307 for role in [
308 "accent_color",
309 "window_bg_color",
310 "view_bg_color",
311 "headerbar_bg_color",
312 "card_bg_color",
313 "borders",
314 ] {
315 assert!(p.lookup(role).is_some(), "dark palette missing role {role}");
316 }
317 }
318
319 #[test]
320 fn lookup_accepts_at_prefix() {
321 let p = Palette::adwaita_light();
322 assert_eq!(p.lookup("accent_color"), p.lookup("@accent_color"));
323 }
324
325 #[test]
326 fn with_overrides_existing_role() {
327 let p = Palette::adwaita_light().with("accent_color", "#ff00ff");
328 assert_eq!(p.lookup("accent_color"), Some(Color::rgb(1.0, 0.0, 1.0)));
329 }
330
331 #[test]
332 fn light_and_dark_window_bg_differ() {
333 let l = Palette::adwaita_light().lookup("window_bg_color").unwrap();
334 let d = Palette::adwaita_dark().lookup("window_bg_color").unwrap();
335 assert_ne!(l, d, "light and dark window backgrounds collapsed");
336 }
337
338 #[test]
339 fn from_rgb_tuple_round_trips() {
340 let c: Color = (255, 0, 128).into();
341 assert!((c.r - 1.0).abs() < 1e-3);
342 assert!(c.g.abs() < 1e-3);
343 assert!((c.b - 128.0 / 255.0).abs() < 1e-3);
344 assert!((c.a - 1.0).abs() < 1e-3);
345 }
346
347 #[test]
348 fn from_hex_six_digit() {
349 let c: Color = "#ff8000".into();
350 assert!((c.r - 1.0).abs() < 1e-3);
351 assert!((c.g - 128.0 / 255.0).abs() < 1e-3);
352 assert!(c.b.abs() < 1e-3);
353 }
354
355 #[test]
356 fn from_hex_eight_digit_carries_alpha() {
357 let c: Color = "#80808080".into();
358 assert!((c.a - 128.0 / 255.0).abs() < 1e-3);
359 }
360
361 #[test]
362 fn root_vars_hyphenates_role_names() {
363 let p = Palette::new().with("accent_color", "#3584e4");
364 let vars = p.root_vars();
365 assert!(
366 vars.contains_key("accent-color"),
367 "expected the hyphenated key 'accent-color', got {:?}",
368 vars.keys().collect::<Vec<_>>()
369 );
370 assert!(
371 !vars.contains_key("accent_color"),
372 "the underscored key must not also be present"
373 );
374 }
375
376 #[test]
377 fn root_vars_formats_opaque_color_as_eight_digit_hex() {
378 let p = Palette::new().with("window_bg_color", "#fafafb");
379 let vars = p.root_vars();
380 assert_eq!(
381 vars.get("window-bg-color").map(String::as_str),
382 Some("#fafafbff")
383 );
384 }
385
386 #[test]
387 fn root_vars_preserves_alpha_channel() {
388 let p = Palette::new().with("shade_color", Color::rgba(0.0, 0.0, 0.0, 0.07));
389 let vars = p.root_vars();
390 // 0.07 * 255 rounds to 18 = 0x12.
391 assert_eq!(
392 vars.get("shade-color").map(String::as_str),
393 Some("#00000012")
394 );
395 }
396
397 #[test]
398 fn root_vars_covers_every_color_in_the_palette() {
399 let p = Palette::adwaita_dark();
400 let vars = p.root_vars();
401 assert_eq!(vars.len(), p.colors.len());
402 }
403}