1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct PaletteRon {
23 required: Vec<String>,
24}
25
26impl PaletteRon {
27 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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
64pub struct PaletteDef {
65 pub colors: BTreeMap<String, [u8; 3]>,
67}
68
69impl PaletteDef {
70 pub fn get(&self, name: &str) -> Option<[u8; 3]> {
72 self.colors.get(name).copied()
73 }
74
75 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
115pub 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
135pub 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
149pub fn format_hex([r, g, b]: [u8; 3]) -> String {
151 format!("#{r:02x}{g:02x}{b:02x}")
152}
153
154pub 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 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 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 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 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}