Skip to main content

damascene_core/icons/
svg.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 damascene_core::prelude::*;
10//! use damascene_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
29// Lock in full per-item documentation for this module (issue #73).
30#![warn(missing_docs)]
31
32use std::collections::hash_map::DefaultHasher;
33use std::hash::{Hash, Hasher};
34use std::sync::Arc;
35
36use crate::tree::IconName;
37use crate::vector::{
38    VectorAsset, VectorParseError, parse_current_color_svg_asset, parse_svg_asset,
39};
40
41/// An SVG icon supplied by the application.
42///
43/// Construct with [`Self::parse`] (paint preserved as authored) or
44/// [`Self::parse_current_color`] (paint replaced with `currentColor`,
45/// so the element's `text_color` tints the icon and `stroke_width`
46/// modulates strokes — matches the lucide-style monochrome icons in
47/// the built-in set).
48#[derive(Clone)]
49pub struct SvgIcon {
50    inner: Arc<SvgIconInner>,
51}
52
53struct SvgIconInner {
54    asset: VectorAsset,
55    content_hash: u64,
56    paint_mode: SvgIconPaintMode,
57}
58
59/// How an [`SvgIcon`]'s paint was interpreted at parse time. Part of the
60/// icon's identity: the same SVG bytes parsed in each mode hash to two
61/// distinct backend cache entries.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum SvgIconPaintMode {
64    /// Fills and strokes kept as authored ([`SvgIcon::parse`]); the
65    /// element's `text_color` / `stroke_width` have no effect.
66    Authored,
67    /// Every fill/stroke treated as `currentColor`
68    /// ([`SvgIcon::parse_current_color`]); the element's `text_color`
69    /// tints and `stroke_width` modulates — like the built-in icons.
70    CurrentColorMask,
71}
72
73impl SvgIcon {
74    /// Parse an SVG, preserving every fill and stroke as authored. The
75    /// element's `text_color` and `stroke_width` settings do not affect
76    /// this icon. Use this for full-color art (logos, illustrations).
77    pub fn parse(svg: &str) -> Result<Self, VectorParseError> {
78        let asset = parse_svg_asset(svg)?;
79        Ok(Self::from_asset(
80            asset,
81            hash_svg(svg, false),
82            SvgIconPaintMode::Authored,
83        ))
84    }
85
86    /// Parse an SVG and treat every fill/stroke as `currentColor`. The
87    /// element's `text_color` tints the icon and `stroke_width`
88    /// modulates strokes — matches how the built-in lucide icons work.
89    pub fn parse_current_color(svg: &str) -> Result<Self, VectorParseError> {
90        let asset = parse_current_color_svg_asset(svg)?;
91        Ok(Self::from_asset(
92            asset,
93            hash_svg(svg, true),
94            SvgIconPaintMode::CurrentColorMask,
95        ))
96    }
97
98    fn from_asset(asset: VectorAsset, content_hash: u64, paint_mode: SvgIconPaintMode) -> Self {
99        Self {
100            inner: Arc::new(SvgIconInner {
101                asset,
102                content_hash,
103                paint_mode,
104            }),
105        }
106    }
107
108    /// Parsed IR — same shape the built-in icons use, so any backend
109    /// that can render an [`IconName`] can render this.
110    pub fn vector_asset(&self) -> &VectorAsset {
111        &self.inner.asset
112    }
113
114    /// Stable per-process identity. Two `SvgIcon`s parsed from the
115    /// same input and paint mode share this value, so backend caches
116    /// dedup them automatically.
117    pub fn content_hash(&self) -> u64 {
118        self.inner.content_hash
119    }
120
121    /// Which paint interpretation this icon was parsed with.
122    pub fn paint_mode(&self) -> SvgIconPaintMode {
123        self.inner.paint_mode
124    }
125}
126
127impl PartialEq for SvgIcon {
128    fn eq(&self, other: &Self) -> bool {
129        self.inner.content_hash == other.inner.content_hash
130    }
131}
132
133impl Eq for SvgIcon {}
134
135impl std::fmt::Debug for SvgIcon {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.debug_struct("SvgIcon")
138            .field(
139                "content_hash",
140                &format_args!("{:016x}", self.inner.content_hash),
141            )
142            .field("paths", &self.inner.asset.paths.len())
143            .field("paint_mode", &self.inner.paint_mode)
144            .finish()
145    }
146}
147
148/// Source for an icon draw — either a built-in [`IconName`] or an
149/// app-supplied [`SvgIcon`]. APIs accept this via [`IntoIconSource`].
150#[derive(Clone, Debug, PartialEq, Eq)]
151pub enum IconSource {
152    /// One of the built-in (lucide-shaped) icons.
153    Builtin(IconName),
154    /// An app-supplied parsed SVG.
155    Custom(SvgIcon),
156    /// A string-typed icon name that didn't match the built-in
157    /// vocabulary. Preserved verbatim — rather than silently resolved —
158    /// so the bundle lint can flag it ([`crate::FindingKind::UnknownIconName`]);
159    /// the painter falls back to a visible `AlertCircle`.
160    UnknownName(String),
161}
162
163impl IconSource {
164    /// The vector IR for this icon — built-ins are looked up in the
165    /// process-wide static cache; custom icons hold their own.
166    pub fn vector_asset(&self) -> &VectorAsset {
167        match self {
168            IconSource::Builtin(name) => crate::icons::icon_vector_asset(*name),
169            IconSource::Custom(svg) => svg.vector_asset(),
170            IconSource::UnknownName(_) => crate::icons::icon_vector_asset(IconName::AlertCircle),
171        }
172    }
173
174    /// Paint interpretation: built-ins are always `currentColor` masks;
175    /// custom icons report their parse mode.
176    pub fn paint_mode(&self) -> SvgIconPaintMode {
177        match self {
178            IconSource::Builtin(_) => SvgIconPaintMode::CurrentColorMask,
179            IconSource::Custom(svg) => svg.paint_mode(),
180            IconSource::UnknownName(_) => SvgIconPaintMode::CurrentColorMask,
181        }
182    }
183
184    /// Short human-readable label, useful for inspection/dump output.
185    /// Built-ins use their `kebab-case` name; custom icons report
186    /// `"custom:<short hash>"`.
187    pub fn label(&self) -> String {
188        match self {
189            IconSource::Builtin(name) => name.name().to_string(),
190            IconSource::Custom(svg) => format!("custom:{:08x}", svg.content_hash() as u32),
191            IconSource::UnknownName(n) => format!("unknown:{n}"),
192        }
193    }
194}
195
196impl From<IconName> for IconSource {
197    fn from(name: IconName) -> Self {
198        IconSource::Builtin(name)
199    }
200}
201
202impl From<SvgIcon> for IconSource {
203    fn from(svg: SvgIcon) -> Self {
204        IconSource::Custom(svg)
205    }
206}
207
208/// Conversion into an [`IconSource`]. Implemented for [`IconName`],
209/// [`SvgIcon`], and string types (resolved against the built-in
210/// vocabulary, with an `AlertCircle` fallback for unknown names).
211pub trait IntoIconSource {
212    /// Convert `self` into an [`IconSource`].
213    fn into_icon_source(self) -> IconSource;
214}
215
216impl IntoIconSource for IconSource {
217    fn into_icon_source(self) -> IconSource {
218        self
219    }
220}
221
222impl IntoIconSource for IconName {
223    fn into_icon_source(self) -> IconSource {
224        IconSource::Builtin(self)
225    }
226}
227
228impl IntoIconSource for SvgIcon {
229    fn into_icon_source(self) -> IconSource {
230        IconSource::Custom(self)
231    }
232}
233
234impl IntoIconSource for &SvgIcon {
235    fn into_icon_source(self) -> IconSource {
236        IconSource::Custom(self.clone())
237    }
238}
239
240impl IntoIconSource for &str {
241    fn into_icon_source(self) -> IconSource {
242        crate::icons::name_to_source(self)
243    }
244}
245
246impl IntoIconSource for String {
247    fn into_icon_source(self) -> IconSource {
248        crate::icons::name_to_source(&self)
249    }
250}
251
252fn hash_svg(svg: &str, current_color: bool) -> u64 {
253    let mut h = DefaultHasher::new();
254    (current_color as u8).hash(&mut h);
255    svg.as_bytes().hash(&mut h);
256    h.finish()
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    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>"##;
264    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>"##;
265
266    #[test]
267    fn parse_extracts_view_box_and_paths() {
268        let icon = SvgIcon::parse(RED_CIRCLE).unwrap();
269        assert_eq!(icon.vector_asset().view_box, [0.0, 0.0, 24.0, 24.0]);
270        assert!(!icon.vector_asset().paths.is_empty());
271    }
272
273    #[test]
274    fn same_source_dedups_to_same_hash() {
275        let a = SvgIcon::parse(RED_CIRCLE).unwrap();
276        let b = SvgIcon::parse(RED_CIRCLE).unwrap();
277        assert_eq!(a.content_hash(), b.content_hash());
278        assert_eq!(a, b);
279    }
280
281    #[test]
282    fn different_sources_have_different_hashes() {
283        let a = SvgIcon::parse(RED_CIRCLE).unwrap();
284        let b = SvgIcon::parse(BLUE_CIRCLE).unwrap();
285        assert_ne!(a.content_hash(), b.content_hash());
286    }
287
288    #[test]
289    fn parse_mode_is_part_of_identity() {
290        // Same bytes, two parse modes → two distinct atlas keys.
291        let a = SvgIcon::parse(RED_CIRCLE).unwrap();
292        let b = SvgIcon::parse_current_color(RED_CIRCLE).unwrap();
293        assert_ne!(a.content_hash(), b.content_hash());
294        assert_eq!(a.paint_mode(), SvgIconPaintMode::Authored);
295        assert_eq!(b.paint_mode(), SvgIconPaintMode::CurrentColorMask);
296        assert_eq!(
297            IconSource::Builtin(IconName::Settings).paint_mode(),
298            SvgIconPaintMode::CurrentColorMask
299        );
300    }
301
302    #[test]
303    fn malformed_svg_returns_error() {
304        let err = SvgIcon::parse("<not-svg/>");
305        assert!(err.is_err(), "expected parse error, got {err:?}");
306    }
307
308    #[test]
309    fn into_icon_source_for_iconname() {
310        assert_eq!(
311            IconName::Settings.into_icon_source(),
312            IconSource::Builtin(IconName::Settings)
313        );
314    }
315
316    #[test]
317    fn into_icon_source_for_str_uses_builtin_vocab() {
318        assert_eq!(
319            "settings".into_icon_source(),
320            IconSource::Builtin(IconName::Settings)
321        );
322    }
323
324    #[test]
325    fn into_icon_source_for_unknown_str_preserves_name() {
326        // Preserved verbatim (for the lint) rather than silently
327        // resolved; the painter falls back to AlertCircle's asset.
328        assert_eq!(
329            "not-a-real-icon".into_icon_source(),
330            IconSource::UnknownName("not-a-real-icon".to_string())
331        );
332    }
333
334    #[test]
335    fn into_icon_source_for_svg_icon() {
336        let svg = SvgIcon::parse(RED_CIRCLE).unwrap();
337        match svg.clone().into_icon_source() {
338            IconSource::Custom(c) => assert_eq!(c, svg),
339            other => panic!("expected Custom, got {other:?}"),
340        }
341    }
342}