Skip to main content

icon_rs/
lib.rs

1//! SVG icons for CAD apps, embedded at compile time.
2//!
3//! Every icon uses a 24x24 view box and is one of four [`Kind`]s: shaded isometric solids for
4//! features, flat sketch tools, and ink-drawn glyphs and UI icons that an app can tint. Each
5//! icon ships a light-theme and a dark-theme SVG; for any other theme, [`Icon::recolor`] swaps in
6//! your own [`Palette`], and [`Icon::tinted`] paints a glyph or UI icon in any colour.
7
8/// What an icon depicts, which decides how it is drawn and whether it can be tinted.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum Kind {
11    /// Isometric CAD geometry with shaded faces: features, mates, analysis tools.
12    Solid,
13    /// Flat sketch geometry, with the points the user picks in the accent colour.
14    Sketch,
15    /// Small markers such as sketch constraints, legible at 10-16 px.
16    Glyph,
17    /// Interface icons such as undo, folder and search.
18    Line,
19}
20
21impl Kind {
22    /// Glyph and line icons are drawn in ink with at most a small accent detail, so they can be
23    /// tinted freely with [`Icon::tinted`].
24    pub fn is_tintable(self) -> bool {
25        matches!(self, Kind::Glyph | Kind::Line)
26    }
27}
28
29/// One icon: its name, kind, title and SVG source for light and dark backgrounds.
30#[derive(Clone, Copy, Debug)]
31pub struct Icon {
32    /// Kebab-case file stem, such as `"linear-pattern"`.
33    pub name: &'static str,
34    pub kind: Kind,
35    /// Human-readable name, such as `"Linear pattern"`.
36    pub title: &'static str,
37    /// For light backgrounds: dark outline, white to mid-grey faces.
38    pub svg: &'static str,
39    /// For dark backgrounds: light outline, darker faces, same lighting direction.
40    pub svg_dark: &'static str,
41}
42
43impl Icon {
44    /// The SVG for a light or dark background.
45    pub fn themed(&self, dark: bool) -> &'static str {
46        if dark { self.svg_dark } else { self.svg }
47    }
48
49    /// The light SVG with every palette colour replaced by the matching slot in `palette`.
50    pub fn recolor(&self, palette: &Palette) -> String {
51        // Two passes through placeholders, so a new colour that equals a later light slot is
52        // not replaced again.
53        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    /// The light SVG with its ink painted `color`; any accent detail keeps the light accent.
65    /// Meant for [`Kind::is_tintable`] icons; for other kinds only the outlines change.
66    pub fn tinted(&self, color: impl Into<Rgb>) -> String {
67        self.tinted_with(color, Palette::LIGHT.accent)
68    }
69
70    /// The light SVG with its ink painted `color` and its accent detail painted `accent`.
71    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    /// Whether the icon has any accent-coloured detail. Apps that tint by multiplying a
76    /// single-colour raster need a second layer (or a pre-coloured raster) for these.
77    pub fn has_accent(&self) -> bool {
78        self.svg.contains(&Palette::LIGHT.accent.to_hex())
79    }
80}
81
82/// An sRGB colour.
83#[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    /// Parses `#rrggbb` or `rrggbb`.
91    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    /// Lowercase `#rrggbb`, the form the SVGs use.
101    pub fn to_hex(self) -> String {
102        format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
103    }
104
105    /// Moves `percent` of the way towards `other`, rounding to the nearest channel value.
106    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    /// Perceived brightness, 0 to 255 (Rec. 601 weights).
113    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/// The five colours an icon is drawn with.
119#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
120pub struct Palette {
121    /// Outlines and arrow heads.
122    pub ink: Rgb,
123    /// Context faces that point up.
124    pub top: Rgb,
125    /// Context bevels and secondary faces.
126    pub soft: Rgb,
127    /// Context faces on the right.
128    pub mid: Rgb,
129    /// Context faces on the left, in shadow.
130    pub shade: Rgb,
131    /// What the tool acts on or creates: the selected face, the new volume, a sketch tool's
132    /// input points.
133    pub accent: Rgb,
134}
135
136impl Palette {
137    /// The colours `Icon::svg` is drawn with: `Palette::new` of a near-black line and blue accent.
138    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    /// The colours `Icon::svg_dark` is drawn with: `Palette::new` of a near-white line and a
148    /// lighter blue accent.
149    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    /// Builds a palette from a line colour and an accent colour. The four face shades are
159    /// neutral tints of the line colour, because faces are context: they show existing geometry.
160    /// The accent marks what a tool acts on or creates.
161    ///
162    /// A dark line (a light theme) is tinted towards white, a light line (a dark theme) towards
163    /// black, so faces stay lit from above in both. [`Palette::LIGHT`] and [`Palette::DARK`]
164    /// are exactly `new` of their line and accent.
165    ///
166    /// Takes anything that converts to [`Rgb`]; with the `bevy_color` feature that includes
167    /// `bevy_color::Color` and `Srgba`.
168    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    /// This palette with a different accent colour.
186    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    //! Alpha is dropped going to `Rgb` and set to opaque coming back; icons have no transparency.
198
199    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            // Linear colours convert back to sRGB first.
239            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, "](https://rvdende.github.io/icon-rs/icons/", $file, ".svg) ", $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        /// Every icon, grouped by kind in toolbar order.
265        pub const ALL: &[Icon] = &[$($konst),*];
266    };
267}
268
269include!("generated.rs");
270
271/// Looks an icon up by its file stem, such as `"extrude"`.
272pub 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        // The new ink equals the light palette's top colour; outlines must stay white.
326        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        // The site's JavaScript port of Palette::new must give these same values.
362        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}