Skip to main content

shuyi_assets/
palette.rs

1//! Named colours: a palette file (`{"name": "#RRGGBB", ...}`) that all
2//! drawing code refers to by name. The game says which names it needs; a
3//! theme is another palette file with exactly the names of the default
4//! one ([`theme_problems`]). As a format (ADR-0007): [`PaletteRon`].
5use crate::prelude::*;
6use serde::{Deserialize, Serialize};
7
8use alloc::collections::BTreeMap;
9
10use crate::error::ContentError;
11use crate::format::{AssetKind, AssetPath, DecodeCx, Format, TEXT_MAX_BYTES, text};
12use crate::ron::parse_ron;
13
14impl AssetKind for PaletteDef {
15    const NAME: &'static str = "palette";
16}
17
18/// A palette file (`palette`, `.ron`), checked as
19/// [`PaletteDef::from_source`] says against the colours the game
20/// requires.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct PaletteRon {
23    required: Vec<String>,
24}
25
26impl PaletteRon {
27    /// A palette that must hold every colour of `required`.
28    pub fn requiring<S: Into<String>>(required: impl IntoIterator<Item = S>) -> Self {
29        Self {
30            required: required.into_iter().map(Into::into).collect(),
31        }
32    }
33}
34
35impl Format for PaletteRon {
36    type Asset = PaletteDef;
37
38    fn id(&self) -> &'static str {
39        "palette"
40    }
41
42    fn extensions(&self) -> &'static [&'static str] {
43        &["ron"]
44    }
45
46    fn max_bytes(&self) -> usize {
47        TEXT_MAX_BYTES
48    }
49
50    fn decode(
51        &self,
52        file: &AssetPath,
53        bytes: &[u8],
54        _cx: &DecodeCx<'_>,
55    ) -> Result<PaletteDef, Vec<ContentError>> {
56        let source = text(file, bytes)?;
57        let required: Vec<&str> = self.required.iter().map(String::as_str).collect();
58        PaletteDef::from_source(file.shown(), source, &required)
59    }
60}
61
62/// The validated palette: colour name → RGB.
63#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
64pub struct PaletteDef {
65    /// Every colour in the file, by name.
66    pub colors: BTreeMap<String, [u8; 3]>,
67}
68
69impl PaletteDef {
70    /// Looks up a colour by name.
71    pub fn get(&self, name: &str) -> Option<[u8; 3]> {
72        self.colors.get(name).copied()
73    }
74
75    /// Parses and validates palette `source`, attributing errors to `file`.
76    /// Reports every bad hex value and every colour of `required` it lacks.
77    pub fn from_source(
78        file: &str,
79        source: &str,
80        required: &[&str],
81    ) -> Result<Self, Vec<ContentError>> {
82        let raw: BTreeMap<String, String> = parse_ron(file, source).map_err(|e| vec![e])?;
83        let mut errors = Vec::new();
84        let mut colors = BTreeMap::new();
85        for (name, value) in &raw {
86            match parse_hex(value) {
87                Ok(rgb) => {
88                    colors.insert(name.clone(), rgb);
89                }
90                Err(why) => {
91                    let mut e = ContentError::new(file, format!("colour \"{name}\": {why}"));
92                    if let Some(line) = line_of_key(source, name) {
93                        e = e.at(line, None);
94                    }
95                    errors.push(e);
96                }
97            }
98        }
99        for &name in required {
100            if !raw.contains_key(name) {
101                errors.push(ContentError::new(
102                    file,
103                    format!("missing required colour \"{name}\""),
104                ));
105            }
106        }
107        if errors.is_empty() {
108            Ok(Self { colors })
109        } else {
110            Err(errors)
111        }
112    }
113}
114
115/// What is wrong with theme `palette` (its file shown as `file`): it must
116/// define exactly the colour names of the default theme's `base` (a name
117/// missing would leave something unpainted; an extra one is a typo).
118pub fn theme_problems(file: &str, base: &PaletteDef, palette: &PaletteDef) -> Vec<ContentError> {
119    let missing = base
120        .colors
121        .keys()
122        .filter(|n| !palette.colors.contains_key(*n))
123        .map(|name| format!("missing colour \"{name}\""));
124    let extra = palette
125        .colors
126        .keys()
127        .filter(|n| !base.colors.contains_key(*n))
128        .map(|name| format!("colour \"{name}\" is not in the default theme"));
129    missing
130        .chain(extra)
131        .map(|message| ContentError::new(file, message))
132        .collect()
133}
134
135/// Parses `#RRGGBB` (hex digits in either case) into RGB.
136pub fn parse_hex(s: &str) -> Result<[u8; 3], String> {
137    let Some(digits) = s.strip_prefix('#') else {
138        return Err(format!("\"{s}\" must start with '#' (format \"#RRGGBB\")"));
139    };
140    if digits.len() != 6 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) {
141        return Err(format!(
142            "\"{s}\" must be '#' followed by exactly 6 hex digits (format \"#RRGGBB\")"
143        ));
144    }
145    let channel = |i: usize| u8::from_str_radix(&digits[i..i + 2], 16).map_err(|e| e.to_string());
146    Ok([channel(0)?, channel(2)?, channel(4)?])
147}
148
149/// Formats RGB as lowercase `#rrggbb`.
150pub fn format_hex([r, g, b]: [u8; 3]) -> String {
151    format!("#{r:02x}{g:02x}{b:02x}")
152}
153
154/// 1-based line of the first line containing `"key"`, for error positions.
155pub fn line_of_key(source: &str, key: &str) -> Option<u32> {
156    let needle = format!("\"{key}\"");
157    let index = source.lines().position(|l| l.contains(&needle))?;
158    u32::try_from(index + 1).ok()
159}
160
161#[cfg(test)]
162mod tests {
163    use proptest::prelude::*;
164
165    use super::*;
166
167    /// The colours these tests require.
168    const REQUIRED: &[&str] = &["black", "white", "text", "player", "enemy", "cursor"];
169
170    fn full_palette_except(skip: &str, extra: &str) -> String {
171        let lines: Vec<String> = REQUIRED
172            .iter()
173            .filter(|&&name| name != skip)
174            .map(|name| format!("    \"{name}\": \"#010203\",\n"))
175            .collect();
176        format!("{{\n{}{extra}}}", lines.concat())
177    }
178
179    fn from_source(file: &str, source: &str) -> Result<PaletteDef, Vec<ContentError>> {
180        PaletteDef::from_source(file, source, REQUIRED)
181    }
182
183    #[test]
184    fn hex_valid_upper_and_lower() {
185        assert_eq!(parse_hex("#1a2B3c"), Ok([0x1a, 0x2b, 0x3c]));
186        assert_eq!(parse_hex("#FFFFFF"), Ok([255, 255, 255]));
187        assert_eq!(parse_hex("#000000"), Ok([0, 0, 0]));
188    }
189
190    #[test]
191    fn hex_missing_hash() {
192        assert!(parse_hex("1a2b3c").is_err_and(|e| e.contains("'#'")));
193    }
194
195    #[test]
196    fn hex_wrong_length() {
197        assert!(parse_hex("#1a2b3").is_err());
198        assert!(parse_hex("#1a2b3c4").is_err());
199        assert!(parse_hex("#").is_err());
200    }
201
202    #[test]
203    fn hex_non_hex_digits() {
204        assert!(parse_hex("#1a2g3c").is_err());
205        assert!(parse_hex("#+1+2+3").is_err());
206        // Multi-byte chars must not panic on slicing.
207        assert!(parse_hex("#ééé").is_err());
208    }
209
210    #[test]
211    fn format_hex_is_lowercase_padded() {
212        assert_eq!(format_hex([0x0a, 0xbc, 0x01]), "#0abc01");
213    }
214
215    proptest! {
216        #[test]
217        fn hex_round_trips(c in any::<[u8; 3]>()) {
218            prop_assert_eq!(parse_hex(&format_hex(c)), Ok(c));
219            prop_assert_eq!(parse_hex(&format_hex(c).to_uppercase()), Ok(c));
220        }
221    }
222
223    #[test]
224    fn valid_palette_loads_all_colours() {
225        let src = full_palette_except("", "    \"grass\": \"#00FF00\",\n");
226        let p = from_source("p.ron", &src).unwrap_or_default();
227        assert_eq!(p.get("grass"), Some([0, 255, 0]));
228        assert_eq!(p.get("player"), Some([1, 2, 3]));
229        assert_eq!(p.colors.len(), REQUIRED.len() + 1);
230        // Nothing required: any names will do.
231        let any = PaletteDef::from_source("p.ron", "{\"x\": \"#000000\"}", &[]);
232        assert_eq!(any.map(|p| p.colors.len()), Ok(1));
233    }
234
235    #[test]
236    fn missing_required_colour_is_error() {
237        let src = full_palette_except("player", "");
238        let errs = from_source("p.ron", &src).err().unwrap_or_default();
239        assert_eq!(errs.len(), 1);
240        assert_eq!(
241            errs[0].to_string(),
242            "p.ron: missing required colour \"player\""
243        );
244    }
245
246    #[test]
247    fn bad_hex_is_error_with_line() {
248        let src = full_palette_except("", "    \"grass\": \"00ff00\",\n");
249        let errs = from_source("p.ron", &src).err().unwrap_or_default();
250        assert_eq!(errs.len(), 1);
251        let line = u32::try_from(REQUIRED.len() + 2).unwrap_or(0);
252        assert_eq!(errs[0].line, Some(line));
253        assert!(errs[0].message.contains("\"grass\""));
254        assert!(errs[0].message.contains("must start with '#'"));
255    }
256
257    #[test]
258    fn multiple_errors_reported_together() {
259        let src = full_palette_except(
260            "enemy",
261            "    \"grass\": \"#12\",\n    \"road\": \"#zzzzzz\",\n",
262        );
263        let errs = from_source("p.ron", &src).err().unwrap_or_default();
264        assert_eq!(errs.len(), 3);
265        let text: Vec<String> = errs.iter().map(ToString::to_string).collect();
266        assert!(text.iter().any(|t| t.contains("\"enemy\"")));
267        assert!(text.iter().any(|t| t.contains("\"grass\"")));
268        assert!(text.iter().any(|t| t.contains("\"road\"")));
269    }
270
271    #[test]
272    fn syntax_error_is_single_positioned_error() {
273        let errs = from_source("p.ron", "{\n  \"a\" \"#000000\"\n}")
274            .err()
275            .unwrap_or_default();
276        assert_eq!(errs.len(), 1);
277        assert_eq!(errs[0].line, Some(2));
278    }
279
280    #[test]
281    fn line_of_key_finds_quoted_key() {
282        let src = "{\n \"text_dim\": \"#000000\",\n \"text\": \"#000000\",\n}";
283        assert_eq!(line_of_key(src, "text"), Some(3));
284        assert_eq!(line_of_key(src, "text_dim"), Some(2));
285        assert_eq!(line_of_key(src, "nope"), None);
286    }
287
288    fn palette(names: &[&str]) -> PaletteDef {
289        PaletteDef {
290            colors: names.iter().map(|&n| (n.to_owned(), [1, 2, 3])).collect(),
291        }
292    }
293
294    #[test]
295    fn a_theme_must_have_exactly_the_default_themes_names() {
296        let base = palette(&["a", "b", "c"]);
297        // Other colours, same names: nothing wrong.
298        let mut same = palette(&["a", "b", "c"]);
299        same.colors.insert("a".into(), [9, 9, 9]);
300        let found = theme_problems("t.ron", &base, &same);
301        assert!(found.is_empty(), "{found:?}");
302        let other = palette(&["a", "c", "d", "e"]);
303        let text: Vec<String> = theme_problems("t.ron", &base, &other)
304            .iter()
305            .map(ToString::to_string)
306            .collect();
307        assert_eq!(
308            text,
309            [
310                "t.ron: missing colour \"b\"",
311                "t.ron: colour \"d\" is not in the default theme",
312                "t.ron: colour \"e\" is not in the default theme",
313            ]
314        );
315    }
316}