Skip to main content

shuyi_ui/frame/
color.rs

1//! Colors: [`Rgb`] values and the [`Palette`] of named colors (game ADR-0012).
2//!
3//! Code refers to colors by name, never raw RGB. The colors a game's code
4//! needs are its [`ColorSet`], an enum whose every name the palette must
5//! have, so a missing name is impossible at render time; the engine's own
6//! widgets ask for the few [`EngineColor`]s, which the game's set names.
7//! Data-driven names (terrain) go through [`Palette::lookup`] and are
8//! validated when their content is loaded.
9
10use crate::prelude::*;
11use alloc::collections::BTreeMap;
12use core::any::TypeId;
13
14use shuyi_assets::palette::PaletteDef;
15use shuyi_core::Lerp;
16
17/// A 24-bit RGB colour. Ordered red, then green, then blue: an order for
18/// maps, not a look.
19#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
20pub struct Rgb {
21    /// Red channel.
22    pub r: u8,
23    /// Green channel.
24    pub g: u8,
25    /// Blue channel.
26    pub b: u8,
27}
28
29impl Rgb {
30    /// Builds a colour from its channels.
31    pub const fn new(r: u8, g: u8, b: u8) -> Self {
32        Self { r, g, b }
33    }
34
35    /// Linear interpolation from `self` (`t = 0`) to `other` (`t = 1`).
36    /// `t` is clamped to `0..=1`; NaN counts as 0.
37    #[must_use]
38    pub fn lerp(self, other: Rgb, t: f32) -> Rgb {
39        let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
40        let mix = |a: u8, b: u8| to_channel(f32::from(a) + (f32::from(b) - f32::from(a)) * t);
41        Rgb::new(
42            mix(self.r, other.r),
43            mix(self.g, other.g),
44            mix(self.b, other.b),
45        )
46    }
47
48    /// Multiplies every channel by `factor` (e.g. `0.5` dims by half),
49    /// saturating at 0 and 255. NaN counts as 0.
50    #[must_use]
51    pub fn scale(self, factor: f32) -> Rgb {
52        let f = if factor.is_nan() { 0.0 } else { factor };
53        let s = |c: u8| to_channel(f32::from(c) * f);
54        Rgb::new(s(self.r), s(self.g), s(self.b))
55    }
56
57    /// This color grey and darker (a game's unit that has acted, painted
58    /// [`Paint::Dimmed`](super::Paint::Dimmed)): [`DIM_GREY`] of the way to
59    /// its own grey, then [`DIM_DARK`] as bright. A change of brightness,
60    /// not of hue.
61    #[must_use]
62    pub fn dimmed(self) -> Rgb {
63        let [r, g, b] = [self.r, self.g, self.b].map(f32::from);
64        let grey = 0.299 * r + 0.587 * g + 0.114 * b;
65        let dim = |c: f32| to_channel((c + (grey - c) * DIM_GREY) * DIM_DARK);
66        Rgb::new(dim(r), dim(g), dim(b))
67    }
68
69    /// Lowercase `#rrggbb`.
70    pub fn to_hex(self) -> String {
71        shuyi_assets::palette::format_hex([self.r, self.g, self.b])
72    }
73}
74
75/// Channel by channel, in integers: what a [`Tween`](shuyi_core::Tween) of
76/// a colour goes through. Past its ends each channel stops at 0 and 255.
77/// [`Rgb::lerp`] is the same in `f32`.
78impl Lerp for Rgb {
79    fn mix(from: Self, to: Self, p: i32) -> Self {
80        Rgb::new(
81            u8::mix(from.r, to.r, p),
82            u8::mix(from.g, to.g, p),
83            u8::mix(from.b, to.b, p),
84        )
85    }
86}
87
88impl From<[u8; 3]> for Rgb {
89    fn from([r, g, b]: [u8; 3]) -> Self {
90        Self::new(r, g, b)
91    }
92}
93
94/// The colour opaque, as an RGBA pixel: what
95/// [`Image::filled`](shuyi_assets::image::Image::filled) takes.
96impl From<Rgb> for [u8; 4] {
97    fn from(c: Rgb) -> Self {
98        [c.r, c.g, c.b, 255]
99    }
100}
101
102/// How far a [dimmed](Rgb::dimmed) colour goes to its own grey (`0` = not
103/// at all, `1` = grey). *Tunable.*
104pub const DIM_GREY: f32 = 0.75;
105
106/// How bright a [dimmed](Rgb::dimmed) colour is left (`1` = as bright as it
107/// was). *Tunable*: the first renders used 0.6; Nick, seeing it in the game
108/// on 2026-10-03, chose to lighten it a bit (ticket 0436).
109pub const DIM_DARK: f32 = 0.75;
110
111/// Rounds and saturates a channel value to `0..=255`.
112#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // clamped first
113pub fn to_channel(v: f32) -> u8 {
114    v.round().clamp(0.0, 255.0) as u8
115}
116
117/// The colors the engine's own widgets ask for. A game's [`ColorSet`] says
118/// which of its palette's names each one is
119/// ([`engine_name`](ColorSet::engine_name)).
120#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash)]
121pub enum EngineColor {
122    /// Text.
123    Text,
124    /// Text that matters less: hints, what can't be picked.
125    TextDim,
126    /// A panel's background.
127    Panel,
128    /// A panel's border.
129    Border,
130    /// What the player has picked or is on.
131    Highlight,
132    /// Something going wrong.
133    Warning,
134    /// The border of what has the focus, and the bar of a focused item.
135    Focus,
136    /// Behind everything: the screen around the panels.
137    Background,
138    /// What stands apart from the rest of a highlight: the keys that move
139    /// the cursor, say.
140    Accent,
141    /// Brighter than text: what just changed.
142    Bright,
143}
144
145impl EngineColor {
146    /// Every engine color.
147    pub const ALL: &[EngineColor] = &[
148        EngineColor::Text,
149        EngineColor::TextDim,
150        EngineColor::Panel,
151        EngineColor::Border,
152        EngineColor::Highlight,
153        EngineColor::Warning,
154        EngineColor::Focus,
155        EngineColor::Background,
156        EngineColor::Accent,
157        EngineColor::Bright,
158    ];
159
160    /// Its name, which is the palette's name for it unless the game's
161    /// [`ColorSet::engine_name`] says otherwise.
162    pub const fn name(self) -> &'static str {
163        match self {
164            EngineColor::Text => "text",
165            EngineColor::TextDim => "text_dim",
166            EngineColor::Panel => "panel",
167            EngineColor::Border => "border",
168            EngineColor::Highlight => "highlight",
169            EngineColor::Warning => "warning",
170            EngineColor::Focus => "focus",
171            EngineColor::Background => "background",
172            EngineColor::Accent => "accent",
173            EngineColor::Bright => "bright",
174        }
175    }
176}
177
178/// The colors a game's code draws with, by name: usually an enum, whose
179/// every name its palette must have.
180pub trait ColorSet: Copy + 'static {
181    /// Every color of the set, each at its [`index`](Self::index).
182    const ALL: &'static [Self];
183
184    /// The color's name in the palette.
185    fn name(self) -> &'static str;
186
187    /// Where the color is in [`ALL`](Self::ALL).
188    fn index(self) -> usize;
189
190    /// The palette's name for an engine color. By default its own
191    /// ([`EngineColor::name`]), so the palette must have `text`, `panel`
192    /// and the rest; a game whose palette calls them otherwise says so here.
193    fn engine_name(color: EngineColor) -> &'static str {
194        color.name()
195    }
196}
197
198/// The engine's own colors as a set: for a palette with nothing more than
199/// the engine's names.
200impl ColorSet for EngineColor {
201    const ALL: &'static [Self] = EngineColor::ALL;
202
203    fn name(self) -> &'static str {
204        EngineColor::name(self)
205    }
206
207    fn index(self) -> usize {
208        self as usize
209    }
210}
211
212/// Every name a palette for the set `C` must have: the set's, in its
213/// order, then the engine colors' that it doesn't already name.
214pub fn required_names<C: ColorSet>() -> Vec<&'static str> {
215    let mut names: Vec<&'static str> = C::ALL.iter().map(|c| c.name()).collect();
216    for &e in EngineColor::ALL {
217        let name = C::engine_name(e);
218        if !names.contains(&name) {
219            names.push(name);
220        }
221    }
222    names
223}
224
225/// Named colors, built from the validated [`PaletteDef`] for a game's
226/// [`ColorSet`].
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct Palette {
229    /// The set it was built for.
230    set: TypeId,
231    /// One entry per color of the set, by its [`ColorSet::index`].
232    colors: Vec<Rgb>,
233    /// One entry per [`EngineColor`], in [`EngineColor::ALL`] order.
234    engine: Vec<Rgb>,
235    /// [`required_names`] of the set: the names [`name_of`](Self::name_of)
236    /// prefers, in order.
237    required: Vec<&'static str>,
238    /// Every color in the file, by name.
239    named: BTreeMap<String, Rgb>,
240}
241
242impl Palette {
243    /// Builds the palette for the colors of `C`. Fails with every one of
244    /// [`required_names`] the definition lacks (a palette checked against
245    /// them when it loaded never does).
246    pub fn new<C: ColorSet>(def: &PaletteDef) -> Result<Self, Vec<&'static str>> {
247        let named: BTreeMap<String, Rgb> = def
248            .colors
249            .iter()
250            .map(|(name, &rgb)| (name.clone(), Rgb::from(rgb)))
251            .collect();
252        let required = required_names::<C>();
253        let missing: Vec<&'static str> = required
254            .iter()
255            .copied()
256            .filter(|n| !named.contains_key(*n))
257            .collect();
258        if !missing.is_empty() {
259            return Err(missing);
260        }
261        let colors = C::ALL.iter().map(|c| named[c.name()]).collect();
262        let engine = EngineColor::ALL
263            .iter()
264            .map(|&e| named[C::engine_name(e)])
265            .collect();
266        Ok(Self {
267            set: TypeId::of::<C>(),
268            colors,
269            engine,
270            required,
271            named,
272        })
273    }
274
275    /// The RGB value of a color of the set the palette was built for. A
276    /// color of another set is looked up by its name (black if there is
277    /// no such name).
278    pub fn get<C: ColorSet>(&self, color: C) -> Rgb {
279        if TypeId::of::<C>() == self.set {
280            self.colors[color.index()]
281        } else {
282            self.lookup(color.name()).unwrap_or(Rgb::new(0, 0, 0))
283        }
284    }
285
286    /// The RGB value of an engine color.
287    pub fn engine(&self, color: EngineColor) -> Rgb {
288        self.engine[color as usize]
289    }
290
291    /// Looks up a data-driven color name (e.g. terrain).
292    pub fn lookup(&self, name: &str) -> Option<Rgb> {
293        self.named.get(name).copied()
294    }
295
296    /// Every named color, alphabetically.
297    pub fn iter(&self) -> impl Iterator<Item = (&str, Rgb)> {
298        self.named.iter().map(|(n, &c)| (n.as_str(), c))
299    }
300
301    /// The name of a color with exactly this RGB value, if any. When several
302    /// names share a value, the set's names win (in [`required_names`]
303    /// order), then the alphabetically first other name.
304    pub fn name_of(&self, rgb: Rgb) -> Option<&str> {
305        self.required
306            .iter()
307            .copied()
308            .find(|&n| self.lookup(n) == Some(rgb))
309            .or_else(|| {
310                self.named
311                    .iter()
312                    .find(|&(_, &v)| v == rgb)
313                    .map(|(n, _)| n.as_str())
314            })
315    }
316}
317
318#[cfg(test)]
319pub(crate) mod tests {
320    use super::*;
321
322    /// A palette file of `pairs`.
323    pub(crate) fn def(pairs: &[(&str, [u8; 3])]) -> PaletteDef {
324        PaletteDef {
325            colors: pairs.iter().map(|&(n, c)| (n.to_owned(), c)).collect(),
326        }
327    }
328
329    /// A palette with the engine's names and a little more: what the
330    /// engine's own tests draw with.
331    pub(crate) fn engine_palette() -> Palette {
332        let def = def(&[
333            ("text", [230, 230, 230]),
334            ("text_dim", [128, 128, 128]),
335            ("panel", [20, 24, 40]),
336            ("border", [90, 110, 160]),
337            ("highlight", [250, 210, 90]),
338            ("warning", [220, 60, 50]),
339            ("focus", [120, 150, 220]),
340            ("background", [0, 0, 0]),
341            ("accent", [80, 200, 240]),
342            ("bright", [255, 255, 255]),
343            ("grass", [40, 120, 50]),
344        ]);
345        Palette::new::<EngineColor>(&def).unwrap()
346    }
347
348    /// A game's colors, whose palette calls two engine colors otherwise.
349    #[derive(Copy, Clone, PartialEq, Eq, Debug)]
350    enum Toy {
351        Ink,
352        Hero,
353        Paper,
354    }
355
356    impl ColorSet for Toy {
357        const ALL: &'static [Self] = &[Toy::Ink, Toy::Hero, Toy::Paper];
358
359        fn name(self) -> &'static str {
360            match self {
361                Toy::Ink => "ink",
362                Toy::Hero => "hero",
363                Toy::Paper => "paper",
364            }
365        }
366
367        fn index(self) -> usize {
368            self as usize
369        }
370
371        fn engine_name(color: EngineColor) -> &'static str {
372            match color {
373                EngineColor::Text => "ink",
374                EngineColor::Panel => "paper",
375                other => other.name(),
376            }
377        }
378    }
379
380    fn toy_def() -> PaletteDef {
381        def(&[
382            ("ink", [1, 1, 1]),
383            ("hero", [2, 2, 2]),
384            ("paper", [3, 3, 3]),
385            ("text_dim", [4, 4, 4]),
386            ("border", [5, 5, 5]),
387            ("highlight", [6, 6, 6]),
388            ("warning", [7, 7, 7]),
389            ("text", [8, 8, 8]),
390            ("focus", [10, 10, 10]),
391            ("background", [11, 11, 11]),
392            ("accent", [12, 12, 12]),
393            ("bright", [13, 13, 13]),
394        ])
395    }
396
397    #[test]
398    fn a_colour_is_an_opaque_rgba_pixel() {
399        assert_eq!(<[u8; 4]>::from(Rgb::new(1, 2, 3)), [1, 2, 3, 255]);
400    }
401
402    #[test]
403    fn engine_color_names_and_indices() {
404        let names: Vec<&str> = EngineColor::ALL.iter().map(|c| c.name()).collect();
405        assert_eq!(
406            names,
407            [
408                "text",
409                "text_dim",
410                "panel",
411                "border",
412                "highlight",
413                "warning",
414                "focus",
415                "background",
416                "accent",
417                "bright"
418            ]
419        );
420        for (i, &c) in EngineColor::ALL.iter().enumerate() {
421            assert_eq!(ColorSet::index(c), i);
422            assert_eq!(ColorSet::name(c), c.name());
423            assert_eq!(<EngineColor as ColorSet>::engine_name(c), c.name());
424        }
425        assert_eq!(<EngineColor as ColorSet>::ALL, EngineColor::ALL);
426    }
427
428    #[test]
429    fn required_names_are_the_set_then_the_engine_names_it_lacks() {
430        assert_eq!(
431            required_names::<EngineColor>(),
432            [
433                "text",
434                "text_dim",
435                "panel",
436                "border",
437                "highlight",
438                "warning",
439                "focus",
440                "background",
441                "accent",
442                "bright"
443            ]
444        );
445        assert_eq!(
446            required_names::<Toy>(),
447            [
448                "ink",
449                "hero",
450                "paper",
451                "text_dim",
452                "border",
453                "highlight",
454                "warning",
455                "focus",
456                "background",
457                "accent",
458                "bright"
459            ]
460        );
461    }
462
463    #[test]
464    fn a_palette_gives_the_sets_colors_and_the_engines() {
465        let p = Palette::new::<Toy>(&toy_def()).unwrap();
466        assert_eq!(p.get(Toy::Ink), Rgb::new(1, 1, 1));
467        assert_eq!(p.get(Toy::Hero), Rgb::new(2, 2, 2));
468        assert_eq!(p.get(Toy::Paper), Rgb::new(3, 3, 3));
469        // The game named these two otherwise.
470        assert_eq!(p.engine(EngineColor::Text), Rgb::new(1, 1, 1));
471        assert_eq!(p.engine(EngineColor::Panel), Rgb::new(3, 3, 3));
472        assert_eq!(p.engine(EngineColor::TextDim), Rgb::new(4, 4, 4));
473        assert_eq!(p.engine(EngineColor::Warning), Rgb::new(7, 7, 7));
474        // A color of another set goes by its name.
475        assert_eq!(p.get(EngineColor::Text), Rgb::new(8, 8, 8));
476        assert_eq!(p.get(EngineColor::Panel), Rgb::new(0, 0, 0));
477        let e = engine_palette();
478        assert_eq!(e.get(EngineColor::Highlight), Rgb::new(250, 210, 90));
479        assert_eq!(e.engine(EngineColor::Highlight), Rgb::new(250, 210, 90));
480        assert_eq!(e.get(Toy::Hero), Rgb::new(0, 0, 0));
481    }
482
483    #[test]
484    fn a_palette_lacking_names_says_which() {
485        assert_eq!(
486            Palette::new::<Toy>(&PaletteDef::default()),
487            Err(required_names::<Toy>())
488        );
489        let mut d = toy_def();
490        d.colors.remove("hero");
491        d.colors.remove("warning");
492        // `text` isn't needed: the game paints engine text in `ink`.
493        d.colors.remove("text");
494        assert_eq!(Palette::new::<Toy>(&d), Err(vec!["hero", "warning"]));
495    }
496
497    #[test]
498    fn lookup_and_iter() {
499        let p = engine_palette();
500        assert_eq!(p.lookup("grass"), Some(Rgb::new(40, 120, 50)));
501        assert_eq!(p.lookup("no_such_colour"), None);
502        let all: Vec<(&str, Rgb)> = p.iter().collect();
503        assert_eq!(all.len(), 11);
504        assert!(all.windows(2).all(|w| w[0].0 < w[1].0));
505        assert!(all.iter().all(|&(n, c)| p.lookup(n) == Some(c)));
506    }
507
508    #[test]
509    fn name_of_prefers_the_sets_names_then_alphabetical() {
510        let mut d = toy_def();
511        for (name, rgb) in [
512            ("zeta", [1, 2, 3]),
513            ("alpha", [1, 2, 3]),
514            ("aaa", [2, 2, 2]),
515            ("text", [9, 9, 9]),
516            ("border", [1, 1, 1]),
517        ] {
518            d.colors.insert(name.to_owned(), rgb);
519        }
520        let p = Palette::new::<Toy>(&d).unwrap();
521        assert_eq!(p.name_of(Rgb::new(2, 2, 2)), Some("hero"));
522        // The set's own name before an engine name it doesn't use.
523        assert_eq!(p.name_of(Rgb::new(1, 1, 1)), Some("ink"));
524        assert_eq!(p.name_of(Rgb::new(1, 2, 3)), Some("alpha"));
525        assert_eq!(p.name_of(Rgb::new(9, 9, 9)), Some("text"));
526        assert_eq!(p.name_of(Rgb::new(7, 7, 7)), Some("warning"));
527        assert_eq!(p.name_of(Rgb::new(70, 7, 7)), None);
528    }
529
530    #[test]
531    fn a_dimmed_colour_is_greyer_and_darker() {
532        // A grey stays grey, three quarters as bright.
533        assert_eq!(Rgb::new(200, 200, 200).dimmed(), Rgb::new(150, 150, 150));
534        assert_eq!(Rgb::new(0, 0, 0).dimmed(), Rgb::new(0, 0, 0));
535        assert_eq!(Rgb::new(255, 255, 255).dimmed(), Rgb::new(191, 191, 191));
536        // Red: its grey is 76.245; 255 goes three quarters of the way
537        // there (120.93), the others up to 57.18; then 0.75 of each.
538        assert_eq!(Rgb::new(255, 0, 0).dimmed(), Rgb::new(91, 43, 43));
539        // Green is the brightest to the eye, blue the darkest.
540        assert_eq!(Rgb::new(0, 255, 0).dimmed(), Rgb::new(84, 132, 84));
541        assert_eq!(Rgb::new(0, 0, 255).dimmed(), Rgb::new(16, 16, 64));
542    }
543
544    #[test]
545    fn lerp_endpoints_and_midpoint() {
546        let a = Rgb::new(0, 100, 255);
547        let b = Rgb::new(200, 0, 55);
548        assert_eq!(a.lerp(b, 0.0), a);
549        assert_eq!(a.lerp(b, 1.0), b);
550        assert_eq!(a.lerp(b, 0.5), Rgb::new(100, 50, 155));
551        assert_eq!(a.lerp(b, 0.25), Rgb::new(50, 75, 205));
552    }
553
554    #[test]
555    fn lerp_clamps_t() {
556        let a = Rgb::new(10, 20, 30);
557        let b = Rgb::new(40, 50, 60);
558        assert_eq!(a.lerp(b, -3.0), a);
559        assert_eq!(a.lerp(b, 7.0), b);
560        assert_eq!(a.lerp(b, f32::NAN), a);
561    }
562
563    #[test]
564    fn mix_is_lerp_in_integers_and_stops_at_the_channels_ends() {
565        use shuyi_core::ease::ONE;
566        let a = Rgb::new(0, 100, 255);
567        let b = Rgb::new(200, 0, 55);
568        assert_eq!(Rgb::mix(a, b, 0), a);
569        assert_eq!(Rgb::mix(a, b, ONE), b);
570        assert_eq!(Rgb::mix(a, b, ONE / 2), a.lerp(b, 0.5));
571        assert_eq!(Rgb::mix(a, b, ONE / 4), a.lerp(b, 0.25));
572        // An overshoot goes on in a line and stops at 0 and 255.
573        assert_eq!(Rgb::mix(a, b, -ONE / 10), Rgb::new(0, 110, 255));
574        assert_eq!(Rgb::mix(a, b, 11 * ONE / 10), Rgb::new(220, 0, 35));
575    }
576
577    #[test]
578    fn scale_dims_and_saturates() {
579        let c = Rgb::new(100, 200, 51);
580        assert_eq!(c.scale(0.5), Rgb::new(50, 100, 26));
581        assert_eq!(c.scale(1.0), c);
582        assert_eq!(c.scale(2.0), Rgb::new(200, 255, 102));
583        assert_eq!(c.scale(-1.0), Rgb::new(0, 0, 0));
584        assert_eq!(c.scale(f32::NAN), Rgb::new(0, 0, 0));
585    }
586
587    #[test]
588    fn to_hex_and_from_array() {
589        assert_eq!(Rgb::from([1, 0xab, 255]).to_hex(), "#01abff");
590    }
591}