1#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum Kind {
11 Solid,
13 Sketch,
15 Glyph,
17 Line,
19}
20
21impl Kind {
22 pub fn is_tintable(self) -> bool {
25 matches!(self, Kind::Glyph | Kind::Line)
26 }
27}
28
29#[derive(Clone, Copy, Debug)]
31pub struct Icon {
32 pub name: &'static str,
34 pub kind: Kind,
35 pub title: &'static str,
37 pub svg: &'static str,
39 pub svg_dark: &'static str,
41}
42
43impl Icon {
44 pub fn themed(&self, dark: bool) -> &'static str {
46 if dark { self.svg_dark } else { self.svg }
47 }
48
49 pub fn recolor(&self, palette: &Palette) -> String {
51 let placeholder = |slot: usize| format!("#\0{slot}");
54 let mut svg = self.svg.to_owned();
55 for (slot, from) in Palette::LIGHT.slots().into_iter().enumerate() {
56 svg = svg.replace(&from.to_hex(), &placeholder(slot));
57 }
58 for (slot, to) in palette.slots().into_iter().enumerate() {
59 svg = svg.replace(&placeholder(slot), &to.to_hex());
60 }
61 svg
62 }
63
64 pub fn tinted(&self, color: impl Into<Rgb>) -> String {
67 self.tinted_with(color, Palette::LIGHT.accent)
68 }
69
70 pub fn tinted_with(&self, color: impl Into<Rgb>, accent: impl Into<Rgb>) -> String {
72 self.recolor(&Palette { ink: color.into(), accent: accent.into(), ..Palette::LIGHT })
73 }
74
75 pub fn has_accent(&self) -> bool {
78 self.svg.contains(&Palette::LIGHT.accent.to_hex())
79 }
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
84pub struct Rgb(pub u8, pub u8, pub u8);
85
86impl Rgb {
87 pub const WHITE: Rgb = Rgb(0xff, 0xff, 0xff);
88 pub const BLACK: Rgb = Rgb(0, 0, 0);
89
90 pub fn from_hex(hex: &str) -> Option<Rgb> {
92 let hex = hex.strip_prefix('#').unwrap_or(hex);
93 if hex.len() != 6 || !hex.is_ascii() {
94 return None;
95 }
96 let channel = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
97 Some(Rgb(channel(0)?, channel(2)?, channel(4)?))
98 }
99
100 pub fn to_hex(self) -> String {
102 format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
103 }
104
105 pub fn mix(self, other: Rgb, percent: u32) -> Rgb {
107 let percent = percent.min(100);
108 let channel = |a: u8, b: u8| ((a as u32 * (100 - percent) + b as u32 * percent + 50) / 100) as u8;
109 Rgb(channel(self.0, other.0), channel(self.1, other.1), channel(self.2, other.2))
110 }
111
112 pub fn luma(self) -> u32 {
114 (self.0 as u32 * 299 + self.1 as u32 * 587 + self.2 as u32 * 114) / 1000
115 }
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
120pub struct Palette {
121 pub ink: Rgb,
123 pub top: Rgb,
125 pub soft: Rgb,
127 pub mid: Rgb,
129 pub shade: Rgb,
131 pub accent: Rgb,
134}
135
136impl Palette {
137 pub const LIGHT: Palette = Palette {
139 ink: Rgb(0x26, 0x26, 0x26),
140 top: Rgb(0xff, 0xff, 0xff),
141 soft: Rgb(0xd4, 0xd4, 0xd4),
142 mid: Rgb(0xa4, 0xa4, 0xa4),
143 shade: Rgb(0x74, 0x74, 0x74),
144 accent: Rgb(0x25, 0x63, 0xeb),
145 };
146
147 pub const DARK: Palette = Palette {
150 ink: Rgb(0xe4, 0xe4, 0xe7),
151 top: Rgb(0xa0, 0xa0, 0xa2),
152 soft: Rgb(0x89, 0x89, 0x8b),
153 mid: Rgb(0x70, 0x70, 0x71),
154 shade: Rgb(0x52, 0x52, 0x53),
155 accent: Rgb(0x60, 0xa5, 0xfa),
156 };
157
158 pub fn new(ink: impl Into<Rgb>, accent: impl Into<Rgb>) -> Palette {
169 let (ink, accent) = (ink.into(), accent.into());
170 let (towards, [top, soft, mid, shade]) = if ink.luma() > 127 {
171 (Rgb::BLACK, [30, 40, 51, 64])
172 } else {
173 (Rgb::WHITE, [100, 80, 58, 36])
174 };
175 Palette {
176 ink,
177 top: ink.mix(towards, top),
178 soft: ink.mix(towards, soft),
179 mid: ink.mix(towards, mid),
180 shade: ink.mix(towards, shade),
181 accent,
182 }
183 }
184
185 pub fn with_accent(self, accent: impl Into<Rgb>) -> Palette {
187 Palette { accent: accent.into(), ..self }
188 }
189
190 fn slots(&self) -> [Rgb; 6] {
191 [self.ink, self.top, self.soft, self.mid, self.shade, self.accent]
192 }
193}
194
195#[cfg(feature = "bevy_color")]
196mod bevy_color_impls {
197 use super::Rgb;
200 use bevy_color::{Color, ColorToPacked, Srgba};
201
202 impl From<Rgb> for Srgba {
203 fn from(c: Rgb) -> Srgba {
204 Srgba::rgb_u8(c.0, c.1, c.2)
205 }
206 }
207
208 impl From<Rgb> for Color {
209 fn from(c: Rgb) -> Color {
210 Color::Srgba(c.into())
211 }
212 }
213
214 impl From<Srgba> for Rgb {
215 fn from(c: Srgba) -> Rgb {
216 let [r, g, b] = c.to_u8_array_no_alpha();
217 Rgb(r, g, b)
218 }
219 }
220
221 impl From<Color> for Rgb {
222 fn from(c: Color) -> Rgb {
223 c.to_srgba().into()
224 }
225 }
226
227 #[cfg(test)]
228 mod tests {
229 use super::*;
230 use crate::Palette;
231 use bevy_color::palettes::tailwind;
232
233 #[test]
234 fn round_trips_through_bevy_color() {
235 let sky = Rgb(0x38, 0xbd, 0xf8);
236 assert_eq!(Rgb::from(Color::from(sky)), sky);
237 assert_eq!(Rgb::from(Srgba::from(sky)), sky);
238 assert_eq!(Rgb::from(Color::LinearRgba(Color::from(sky).to_linear())), sky);
240 }
241
242 #[test]
243 fn new_accepts_bevy_colors() {
244 let from_bevy = Palette::new(tailwind::SKY_900, Color::from(tailwind::SKY_400));
245 let from_rgb = Palette::new(Rgb::from(tailwind::SKY_900), Rgb::from(tailwind::SKY_400));
246 assert_eq!(from_bevy, from_rgb);
247 }
248 }
249}
250
251macro_rules! icons {
252 ($($konst:ident => ($file:literal, $kind:ident, $title:literal)),* $(,)?) => {
253 $(
254 #[doc = concat!(" ", $title)]
255 pub const $konst: Icon = Icon {
256 name: $file,
257 kind: Kind::$kind,
258 title: $title,
259 svg: include_str!(concat!("../icons/", $file, ".svg")),
260 svg_dark: include_str!(concat!("../icons/dark/", $file, ".svg")),
261 };
262 )*
263
264 pub const ALL: &[Icon] = &[$($konst),*];
266 };
267}
268
269include!("generated.rs");
270
271pub fn get(name: &str) -> Option<Icon> {
273 ALL.iter().copied().find(|icon| icon.name == name)
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn every_icon_is_a_24px_svg() {
282 for icon in ALL {
283 for svg in [icon.svg, icon.svg_dark] {
284 assert!(svg.starts_with("<svg"), "{} is not an svg", icon.name);
285 assert!(svg.contains(r#"width="24" height="24""#), "{} is not 24x24", icon.name);
286 }
287 }
288 }
289
290 #[test]
291 fn every_svg_file_is_registered() {
292 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("icons");
293 for entry in std::fs::read_dir(dir).unwrap() {
294 let path = entry.unwrap().path();
295 if path.is_dir() {
296 continue;
297 }
298 let stem = path.file_stem().unwrap().to_str().unwrap();
299 assert!(get(stem).is_some(), "icons/{stem}.svg is missing from the icons! list");
300 }
301 }
302
303 #[test]
304 fn dark_variant_is_the_light_svg_recoloured() {
305 for icon in ALL {
306 assert_eq!(icon.recolor(&Palette::DARK), icon.svg_dark, "{}", icon.name);
307 assert_eq!(icon.recolor(&Palette::LIGHT), icon.svg, "{}", icon.name);
308 }
309 }
310
311 #[test]
312 fn light_svgs_only_use_palette_colours() {
313 for icon in ALL {
314 let mut rest = icon.svg;
315 while let Some(at) = rest.find("=\"#") {
316 let hex = Rgb::from_hex(&rest[at + 2..at + 9]).unwrap();
317 assert!(Palette::LIGHT.slots().contains(&hex), "{} uses {hex:?}", icon.name);
318 rest = &rest[at + 9..];
319 }
320 }
321 }
322
323 #[test]
324 fn recolor_does_not_chain_replacements() {
325 let palette = Palette { ink: Palette::LIGHT.top, top: Rgb(1, 2, 3), ..Palette::LIGHT };
327 let svg = SHELL.recolor(&palette);
328 assert!(svg.contains(r##"stroke="#ffffff""##));
329 assert!(svg.contains(r##"fill="#010203""##));
330 }
331
332 #[test]
333 fn tintable_icons_use_only_ink_and_accent() {
334 let accent = Palette::LIGHT.accent.to_hex();
335 for icon in ALL.iter().filter(|i| i.kind.is_tintable()) {
336 let white = icon.tinted(Rgb::WHITE);
337 let colours = white.matches("=\"#").count();
338 let allowed = white.matches("=\"#ffffff").count() + white.matches(&format!("=\"{accent}")).count();
339 assert_eq!(colours, allowed, "{} uses a colour other than ink and accent", icon.name);
340 }
341 }
342
343 #[test]
344 fn tinting_keeps_the_accent() {
345 let icon = ALL.iter().find(|i| i.has_accent()).expect("an accented icon");
346 let svg = icon.tinted_with(Rgb::WHITE, Rgb(1, 2, 3));
347 assert!(svg.contains("#010203") && !svg.contains(&Palette::LIGHT.accent.to_hex()));
348 }
349
350 #[test]
351 fn hex_round_trip() {
352 assert_eq!(Rgb::from_hex("#38bdf8"), Some(Rgb(0x38, 0xbd, 0xf8)));
353 assert_eq!(Rgb::from_hex("38BDF8"), Some(Rgb(0x38, 0xbd, 0xf8)));
354 assert_eq!(Rgb(0x38, 0xbd, 0xf8).to_hex(), "#38bdf8");
355 assert_eq!(Rgb::from_hex("#38bdf"), None);
356 assert_eq!(Rgb::from_hex("#38bdfg"), None);
357 }
358
359 #[test]
360 fn light_and_dark_are_new_of_their_line_and_accent() {
361 assert_eq!(Palette::new(Palette::LIGHT.ink, Palette::LIGHT.accent), Palette::LIGHT);
363 assert_eq!(Palette::new(Palette::DARK.ink, Palette::DARK.accent), Palette::DARK);
364 }
365
366 #[test]
367 fn new_keeps_faces_neutral() {
368 let p = Palette::new(Rgb(0x0c, 0x4a, 0x6e), Rgb(0xf5, 0x9e, 0x0b));
369 assert_eq!(p.accent, Rgb(0xf5, 0x9e, 0x0b));
370 assert_eq!(p.top, Rgb::WHITE);
371 assert!(p.soft.luma() > p.mid.luma() && p.mid.luma() > p.shade.luma());
372 }
373
374 #[test]
375 fn lookup_by_name() {
376 assert_eq!(get("loft").unwrap().name, "loft");
377 assert!(get("nope").is_none());
378 }
379}