damascene_core/icons/
svg.rs1#![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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum SvgIconPaintMode {
64 Authored,
67 CurrentColorMask,
71}
72
73impl SvgIcon {
74 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 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 pub fn vector_asset(&self) -> &VectorAsset {
111 &self.inner.asset
112 }
113
114 pub fn content_hash(&self) -> u64 {
118 self.inner.content_hash
119 }
120
121 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#[derive(Clone, Debug, PartialEq, Eq)]
151pub enum IconSource {
152 Builtin(IconName),
154 Custom(SvgIcon),
156 UnknownName(String),
161}
162
163impl IconSource {
164 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 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 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
208pub trait IntoIconSource {
212 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 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 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}