Skip to main content

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}