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 through
235 /// [`Color::from_hex`]. Falls back to [`Color::default`] on any parse
236 /// error - this impl is intended for the Adwaita defaults table where
237 /// every literal is known-good at compile time.
238 fn from(hex: &'static str) -> Self {
239 Color::from_hex(hex).unwrap_or_default()
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn adwaita_light_carries_core_roles() {
249 let p = Palette::adwaita_light();
250 // Every named color the rewrite spec lists must be populated.
251 for role in [
252 "accent_color",
253 "accent_bg_color",
254 "accent_fg_color",
255 "destructive_color",
256 "destructive_bg_color",
257 "destructive_fg_color",
258 "success_color",
259 "warning_color",
260 "error_color",
261 "window_bg_color",
262 "window_fg_color",
263 "view_bg_color",
264 "view_fg_color",
265 "headerbar_bg_color",
266 "card_bg_color",
267 "sidebar_bg_color",
268 "popover_bg_color",
269 "shade_color",
270 "scrollbar_outline_color",
271 "borders",
272 ] {
273 assert!(
274 p.lookup(role).is_some(),
275 "light palette missing role {role}"
276 );
277 }
278 }
279
280 #[test]
281 fn adwaita_dark_carries_core_roles() {
282 let p = Palette::adwaita_dark();
283 for role in [
284 "accent_color",
285 "window_bg_color",
286 "view_bg_color",
287 "headerbar_bg_color",
288 "card_bg_color",
289 "borders",
290 ] {
291 assert!(p.lookup(role).is_some(), "dark palette missing role {role}");
292 }
293 }
294
295 #[test]
296 fn lookup_accepts_at_prefix() {
297 let p = Palette::adwaita_light();
298 assert_eq!(p.lookup("accent_color"), p.lookup("@accent_color"));
299 }
300
301 #[test]
302 fn with_overrides_existing_role() {
303 let p = Palette::adwaita_light().with("accent_color", "#ff00ff");
304 assert_eq!(p.lookup("accent_color"), Some(Color::rgb(1.0, 0.0, 1.0)));
305 }
306
307 #[test]
308 fn light_and_dark_window_bg_differ() {
309 let l = Palette::adwaita_light().lookup("window_bg_color").unwrap();
310 let d = Palette::adwaita_dark().lookup("window_bg_color").unwrap();
311 assert_ne!(l, d, "light and dark window backgrounds collapsed");
312 }
313
314 #[test]
315 fn from_rgb_tuple_round_trips() {
316 let c: Color = (255, 0, 128).into();
317 assert!((c.r - 1.0).abs() < 1e-3);
318 assert!(c.g.abs() < 1e-3);
319 assert!((c.b - 128.0 / 255.0).abs() < 1e-3);
320 assert!((c.a - 1.0).abs() < 1e-3);
321 }
322
323 #[test]
324 fn from_hex_six_digit() {
325 let c: Color = "#ff8000".into();
326 assert!((c.r - 1.0).abs() < 1e-3);
327 assert!((c.g - 128.0 / 255.0).abs() < 1e-3);
328 assert!(c.b.abs() < 1e-3);
329 }
330
331 #[test]
332 fn from_hex_eight_digit_carries_alpha() {
333 let c: Color = "#80808080".into();
334 assert!((c.a - 128.0 / 255.0).abs() < 1e-3);
335 }
336
337 #[test]
338 fn root_vars_hyphenates_role_names() {
339 let p = Palette::new().with("accent_color", "#3584e4");
340 let vars = p.root_vars();
341 assert!(
342 vars.contains_key("accent-color"),
343 "expected the hyphenated key 'accent-color', got {:?}",
344 vars.keys().collect::<Vec<_>>()
345 );
346 assert!(
347 !vars.contains_key("accent_color"),
348 "the underscored key must not also be present"
349 );
350 }
351
352 #[test]
353 fn root_vars_formats_opaque_color_as_eight_digit_hex() {
354 let p = Palette::new().with("window_bg_color", "#fafafb");
355 let vars = p.root_vars();
356 assert_eq!(
357 vars.get("window-bg-color").map(String::as_str),
358 Some("#fafafbff")
359 );
360 }
361
362 #[test]
363 fn root_vars_preserves_alpha_channel() {
364 let p = Palette::new().with("shade_color", Color::rgba(0.0, 0.0, 0.0, 0.07));
365 let vars = p.root_vars();
366 // 0.07 * 255 rounds to 18 = 0x12.
367 assert_eq!(
368 vars.get("shade-color").map(String::as_str),
369 Some("#00000012")
370 );
371 }
372
373 #[test]
374 fn root_vars_covers_every_color_in_the_palette() {
375 let p = Palette::adwaita_dark();
376 let vars = p.root_vars();
377 assert_eq!(vars.len(), p.colors.len());
378 }
379}