Skip to main content

aetna_core/
svg_icon.rs

1//! App-supplied SVG icons.
2//!
3//! Apps parse an SVG once (typically as a `LazyLock` over an
4//! `include_str!` payload) and pass the resulting [`SvgIcon`] to any
5//! API that accepts a built-in [`IconName`]:
6//!
7//! ```
8//! use std::sync::LazyLock;
9//! use aetna_core::prelude::*;
10//! use aetna_core::SvgIcon;
11//!
12//! // Real apps usually do `include_str!("path/to/logo.svg")`. Inlined
13//! // here so the doctest compiles without a fixture file.
14//! const LOGO_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/></svg>"##;
15//!
16//! static MY_LOGO: LazyLock<SvgIcon> = LazyLock::new(|| {
17//!     SvgIcon::parse_current_color(LOGO_SVG).unwrap()
18//! });
19//!
20//! fn header() -> El {
21//!     icon(MY_LOGO.clone()).icon_size(24.0)
22//! }
23//! ```
24//!
25//! Identity is content-hashed: two `SvgIcon`s parsed from the same
26//! source and paint mode share backend cache entries. Cloning is a cheap
27//! `Arc` bump.
28
29use std::collections::hash_map::DefaultHasher;
30use std::hash::{Hash, Hasher};
31use std::sync::Arc;
32
33use crate::tree::IconName;
34use crate::vector::{
35    VectorAsset, VectorParseError, parse_current_color_svg_asset, parse_svg_asset,
36};
37
38/// An SVG icon supplied by the application.
39///
40/// Construct with [`Self::parse`] (paint preserved as authored) or
41/// [`Self::parse_current_color`] (paint replaced with `currentColor`,
42/// so the element's `text_color` tints the icon and `stroke_width`
43/// modulates strokes — matches the lucide-style monochrome icons in
44/// the built-in set).
45#[derive(Clone)]
46pub struct SvgIcon {
47    inner: Arc<SvgIconInner>,
48}
49
50struct SvgIconInner {
51    asset: VectorAsset,
52    content_hash: u64,
53    paint_mode: SvgIconPaintMode,
54}
55
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum SvgIconPaintMode {
58    Authored,
59    CurrentColorMask,
60}
61
62impl SvgIcon {
63    /// Parse an SVG, preserving every fill and stroke as authored. The
64    /// element's `text_color` and `stroke_width` settings do not affect
65    /// this icon. Use this for full-color art (logos, illustrations).
66    pub fn parse(svg: &str) -> Result<Self, VectorParseError> {
67        let asset = parse_svg_asset(svg)?;
68        Ok(Self::from_asset(
69            asset,
70            hash_svg(svg, false),
71            SvgIconPaintMode::Authored,
72        ))
73    }
74
75    /// Parse an SVG and treat every fill/stroke as `currentColor`. The
76    /// element's `text_color` tints the icon and `stroke_width`
77    /// modulates strokes — matches how the built-in lucide icons work.
78    pub fn parse_current_color(svg: &str) -> Result<Self, VectorParseError> {
79        let asset = parse_current_color_svg_asset(svg)?;
80        Ok(Self::from_asset(
81            asset,
82            hash_svg(svg, true),
83            SvgIconPaintMode::CurrentColorMask,
84        ))
85    }
86
87    fn from_asset(asset: VectorAsset, content_hash: u64, paint_mode: SvgIconPaintMode) -> Self {
88        Self {
89            inner: Arc::new(SvgIconInner {
90                asset,
91                content_hash,
92                paint_mode,
93            }),
94        }
95    }
96
97    /// Parsed IR — same shape the built-in icons use, so any backend
98    /// that can render an [`IconName`] can render this.
99    pub fn vector_asset(&self) -> &VectorAsset {
100        &self.inner.asset
101    }
102
103    /// Stable per-process identity. Two `SvgIcon`s parsed from the
104    /// same input and paint mode share this value, so backend caches
105    /// dedup them automatically.
106    pub fn content_hash(&self) -> u64 {
107        self.inner.content_hash
108    }
109
110    pub fn paint_mode(&self) -> SvgIconPaintMode {
111        self.inner.paint_mode
112    }
113}
114
115impl PartialEq for SvgIcon {
116    fn eq(&self, other: &Self) -> bool {
117        self.inner.content_hash == other.inner.content_hash
118    }
119}
120
121impl Eq for SvgIcon {}
122
123impl std::fmt::Debug for SvgIcon {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.debug_struct("SvgIcon")
126            .field(
127                "content_hash",
128                &format_args!("{:016x}", self.inner.content_hash),
129            )
130            .field("paths", &self.inner.asset.paths.len())
131            .field("paint_mode", &self.inner.paint_mode)
132            .finish()
133    }
134}
135
136/// Source for an icon draw — either a built-in [`IconName`] or an
137/// app-supplied [`SvgIcon`]. APIs accept this via [`IntoIconSource`].
138#[derive(Clone, Debug, PartialEq, Eq)]
139pub enum IconSource {
140    Builtin(IconName),
141    Custom(SvgIcon),
142}
143
144impl IconSource {
145    /// The vector IR for this icon — built-ins are looked up in the
146    /// process-wide static cache; custom icons hold their own.
147    pub fn vector_asset(&self) -> &VectorAsset {
148        match self {
149            IconSource::Builtin(name) => crate::icons::icon_vector_asset(*name),
150            IconSource::Custom(svg) => svg.vector_asset(),
151        }
152    }
153
154    pub fn paint_mode(&self) -> SvgIconPaintMode {
155        match self {
156            IconSource::Builtin(_) => SvgIconPaintMode::CurrentColorMask,
157            IconSource::Custom(svg) => svg.paint_mode(),
158        }
159    }
160
161    /// Short human-readable label, useful for inspection/dump output.
162    /// Built-ins use their `kebab-case` name; custom icons report
163    /// `"custom:<short hash>"`.
164    pub fn label(&self) -> String {
165        match self {
166            IconSource::Builtin(name) => name.name().to_string(),
167            IconSource::Custom(svg) => format!("custom:{:08x}", svg.content_hash() as u32),
168        }
169    }
170}
171
172impl From<IconName> for IconSource {
173    fn from(name: IconName) -> Self {
174        IconSource::Builtin(name)
175    }
176}
177
178impl From<SvgIcon> for IconSource {
179    fn from(svg: SvgIcon) -> Self {
180        IconSource::Custom(svg)
181    }
182}
183
184/// Conversion into an [`IconSource`]. Implemented for [`IconName`],
185/// [`SvgIcon`], and string types (resolved against the built-in
186/// vocabulary, with an `AlertCircle` fallback for unknown names).
187pub trait IntoIconSource {
188    fn into_icon_source(self) -> IconSource;
189}
190
191impl IntoIconSource for IconSource {
192    fn into_icon_source(self) -> IconSource {
193        self
194    }
195}
196
197impl IntoIconSource for IconName {
198    fn into_icon_source(self) -> IconSource {
199        IconSource::Builtin(self)
200    }
201}
202
203impl IntoIconSource for SvgIcon {
204    fn into_icon_source(self) -> IconSource {
205        IconSource::Custom(self)
206    }
207}
208
209impl IntoIconSource for &SvgIcon {
210    fn into_icon_source(self) -> IconSource {
211        IconSource::Custom(self.clone())
212    }
213}
214
215impl IntoIconSource for &str {
216    fn into_icon_source(self) -> IconSource {
217        IconSource::Builtin(crate::icons::name_or_fallback(self))
218    }
219}
220
221impl IntoIconSource for String {
222    fn into_icon_source(self) -> IconSource {
223        IconSource::Builtin(crate::icons::name_or_fallback(&self))
224    }
225}
226
227fn hash_svg(svg: &str, current_color: bool) -> u64 {
228    let mut h = DefaultHasher::new();
229    (current_color as u8).hash(&mut h);
230    svg.as_bytes().hash(&mut h);
231    h.finish()
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    const RED_CIRCLE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="#ff0000"/></svg>"##;
239    const BLUE_CIRCLE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="#0000ff"/></svg>"##;
240
241    #[test]
242    fn parse_extracts_view_box_and_paths() {
243        let icon = SvgIcon::parse(RED_CIRCLE).unwrap();
244        assert_eq!(icon.vector_asset().view_box, [0.0, 0.0, 24.0, 24.0]);
245        assert!(!icon.vector_asset().paths.is_empty());
246    }
247
248    #[test]
249    fn same_source_dedups_to_same_hash() {
250        let a = SvgIcon::parse(RED_CIRCLE).unwrap();
251        let b = SvgIcon::parse(RED_CIRCLE).unwrap();
252        assert_eq!(a.content_hash(), b.content_hash());
253        assert_eq!(a, b);
254    }
255
256    #[test]
257    fn different_sources_have_different_hashes() {
258        let a = SvgIcon::parse(RED_CIRCLE).unwrap();
259        let b = SvgIcon::parse(BLUE_CIRCLE).unwrap();
260        assert_ne!(a.content_hash(), b.content_hash());
261    }
262
263    #[test]
264    fn parse_mode_is_part_of_identity() {
265        // Same bytes, two parse modes → two distinct atlas keys.
266        let a = SvgIcon::parse(RED_CIRCLE).unwrap();
267        let b = SvgIcon::parse_current_color(RED_CIRCLE).unwrap();
268        assert_ne!(a.content_hash(), b.content_hash());
269        assert_eq!(a.paint_mode(), SvgIconPaintMode::Authored);
270        assert_eq!(b.paint_mode(), SvgIconPaintMode::CurrentColorMask);
271        assert_eq!(
272            IconSource::Builtin(IconName::Settings).paint_mode(),
273            SvgIconPaintMode::CurrentColorMask
274        );
275    }
276
277    #[test]
278    fn malformed_svg_returns_error() {
279        let err = SvgIcon::parse("<not-svg/>");
280        assert!(err.is_err(), "expected parse error, got {err:?}");
281    }
282
283    #[test]
284    fn into_icon_source_for_iconname() {
285        assert_eq!(
286            IconName::Settings.into_icon_source(),
287            IconSource::Builtin(IconName::Settings)
288        );
289    }
290
291    #[test]
292    fn into_icon_source_for_str_uses_builtin_vocab() {
293        assert_eq!(
294            "settings".into_icon_source(),
295            IconSource::Builtin(IconName::Settings)
296        );
297    }
298
299    #[test]
300    fn into_icon_source_for_unknown_str_falls_back() {
301        assert_eq!(
302            "not-a-real-icon".into_icon_source(),
303            IconSource::Builtin(IconName::AlertCircle)
304        );
305    }
306
307    #[test]
308    fn into_icon_source_for_svg_icon() {
309        let svg = SvgIcon::parse(RED_CIRCLE).unwrap();
310        match svg.clone().into_icon_source() {
311            IconSource::Custom(c) => assert_eq!(c, svg),
312            other => panic!("expected Custom, got {other:?}"),
313        }
314    }
315}