1use 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#[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 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 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 pub fn vector_asset(&self) -> &VectorAsset {
100 &self.inner.asset
101 }
102
103 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#[derive(Clone, Debug, PartialEq, Eq)]
139pub enum IconSource {
140 Builtin(IconName),
141 Custom(SvgIcon),
142}
143
144impl IconSource {
145 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 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
184pub 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 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}