1use std::sync::OnceLock;
7
8#[derive(Debug, Clone, Copy)]
10pub struct IconRef {
11 pub material: &'static str,
13 pub emoji: &'static str,
15}
16
17pub fn resolve_sf_symbol(name: &str) -> IconRef {
19 let n = normalize(name);
20 if let Some(r) = table().get(n.as_str()).copied() {
21 return r;
22 }
23 let material = heuristic_material(&n);
25 IconRef {
26 material: intern_material(material),
27 emoji: "•",
28 }
29}
30
31pub fn sf_symbol_emoji(name: &str) -> &'static str {
33 resolve_sf_symbol(name).emoji
34}
35
36pub fn sf_symbol_material(name: &str) -> &'static str {
38 resolve_sf_symbol(name).material
39}
40
41fn normalize(name: &str) -> String {
42 name.trim().to_ascii_lowercase()
43}
44
45fn heuristic_material(n: &str) -> String {
46 let base = n
47 .trim_end_matches(".fill")
48 .trim_end_matches(".circle")
49 .trim_end_matches(".square")
50 .replace(['.', '-'], "_");
51 if base.is_empty() {
52 "circle".into()
53 } else {
54 base
55 }
56}
57
58fn intern_material(s: String) -> &'static str {
59 Box::leak(s.into_boxed_str())
61}
62
63static LOOKUP: OnceLock<std::collections::HashMap<&'static str, IconRef>> = OnceLock::new();
64
65fn table() -> &'static std::collections::HashMap<&'static str, IconRef> {
66 LOOKUP.get_or_init(|| {
67 let mut m = std::collections::HashMap::new();
68 for (sf, material, emoji) in ENTRIES {
69 m.insert(*sf, IconRef { material, emoji });
70 }
71 m
72 })
73}
74
75const ENTRIES: &[(&str, &str, &str)] = &[
77 ("star", "star", "⭐"),
78 ("star.fill", "star", "⭐"),
79 ("heart", "favorite", "❤"),
80 ("heart.fill", "favorite", "❤"),
81 ("person", "person", "👤"),
82 ("person.fill", "person", "👤"),
83 ("person.2.fill", "group", "👥"),
84 ("person.badge.plus", "person_add", "👤"),
85 ("gear", "settings", "⚙"),
86 ("gearshape", "settings", "⚙"),
87 ("gearshape.fill", "settings", "⚙"),
88 ("checkmark", "check", "✓"),
89 ("checkmark.circle", "check_circle", "✓"),
90 ("checkmark.circle.fill", "check_circle", "✓"),
91 ("xmark", "close", "✕"),
92 ("xmark.circle", "cancel", "✕"),
93 ("plus", "add", "+"),
94 ("plus.circle", "add_circle", "+"),
95 ("minus", "remove", "−"),
96 ("minus.circle", "do_not_disturb_on", "−"),
97 ("bell", "notifications", "🔔"),
98 ("bell.fill", "notifications", "🔔"),
99 ("house", "home", "⌂"),
100 ("house.fill", "home", "⌂"),
101 ("magnifyingglass", "search", "🔍"),
102 ("calendar", "calendar_today", "📅"),
103 ("calendar.badge.clock", "event", "📅"),
104 ("calendar.circle", "calendar_today", "📅"),
105 ("clock", "schedule", "⏱"),
106 ("clock.fill", "schedule", "⏱"),
107 ("globe", "public", "🌐"),
108 ("cloud.fill", "cloud", "☁"),
109 ("cloud.sun.fill", "partly_cloudy_day", "⛅"),
110 ("cloud.rain.fill", "rainy", "☔"),
111 ("cloud.bolt.fill", "thunderstorm", "⚡"),
112 ("sun.max", "wb_sunny", "☀"),
113 ("sun.max.fill", "wb_sunny", "☀"),
114 ("moon.fill", "dark_mode", "☾"),
115 ("moon.stars.fill", "clear_night", "☾"),
116 ("bolt", "bolt", "⚡"),
117 ("bolt.fill", "bolt", "⚡"),
118 ("location.fill", "location_on", "⌂"),
119 ("flame.fill", "local_fire_department", "🔥"),
120 ("drop.fill", "water_drop", "●"),
121 ("leaf.fill", "eco", "✿"),
122 ("eye.fill", "visibility", "◉"),
123 ("bubble.left.fill", "chat_bubble", "◑"),
124 ("music.note", "music_note", "♪"),
125 ("music.note.list", "queue_music", "♪"),
126 ("hourglass", "hourglass_empty", "⏳"),
127 ("hourglass.bottomhalf.filled", "hourglass_bottom", "⏳"),
128 ("figure.run", "directions_run", "➤"),
129 ("paintpalette.fill", "palette", "⚘"),
130 ("quote.opening", "format_quote", "“"),
131 ("bitcoinsign.circle.fill", "currency_bitcoin", "₿"),
132 ("battery.100", "battery_full", "▮"),
133 ("iphone", "phone_iphone", "▯"),
134 ("ipad", "tablet_mac", "▭"),
135 ("applewatch", "watch", "⌚"),
136 ("airpodspro", "headphones", "○"),
137 ("desktopcomputer", "desktop_windows", "▣"),
138 ("internaldrive", "hard_drive", "■"),
139 ("network", "lan", "≡"),
140 ("envelope.fill", "mail", "✉"),
141 ("envelope", "mail", "✉"),
142 ("phone.fill", "call", "☎"),
143 ("phone", "call", "☎"),
144 ("message.fill", "message", "💬"),
145 ("trash", "delete", "🗑"),
146 ("trash.fill", "delete", "🗑"),
147 ("folder", "folder", "📁"),
148 ("folder.fill", "folder", "📁"),
149 ("doc", "description", "📄"),
150 ("doc.fill", "description", "📄"),
151 ("photo", "photo", "🖼"),
152 ("photo.fill", "photo", "🖼"),
153 ("camera", "photo_camera", "📷"),
154 ("camera.fill", "photo_camera", "📷"),
155 ("map", "map", "🗺"),
156 ("map.fill", "map", "🗺"),
157 ("cart", "shopping_cart", "🛒"),
158 ("cart.fill", "shopping_cart", "🛒"),
159 ("creditcard", "credit_card", "💳"),
160 ("creditcard.fill", "credit_card", "💳"),
161 ("wifi", "wifi", "📶"),
162 ("antenna.radiowaves.left.and.right", "cell_tower", "📡"),
163 ("lock.fill", "lock", "🔒"),
164 ("lock", "lock", "🔒"),
165 ("lock.open.fill", "lock_open", "🔓"),
166 ("key.fill", "key", "🔑"),
167 ("paperplane.fill", "send", "➤"),
168 ("arrow.right", "arrow_forward", "→"),
169 ("arrow.left", "arrow_back", "←"),
170 ("arrow.up", "arrow_upward", "↑"),
171 ("arrow.down", "arrow_downward", "↓"),
172 ("chevron.right", "chevron_right", "›"),
173 ("chevron.left", "chevron_left", "‹"),
174 ("chevron.up", "expand_less", "ˆ"),
175 ("chevron.down", "expand_more", "ˇ"),
176 ("info.circle", "info", "ℹ"),
177 ("info.circle.fill", "info", "ℹ"),
178 ("exclamationmark.triangle", "warning", "⚠"),
179 ("exclamationmark.triangle.fill", "warning", "⚠"),
180 ("play.fill", "play_arrow", "▶"),
181 ("pause.fill", "pause", "⏸"),
182 ("stop.fill", "stop", "⏹"),
183 ("forward.fill", "fast_forward", "⏩"),
184 ("backward.fill", "fast_rewind", "⏪"),
185];
186
187pub fn icon_svg(name: &str, size: f64, color: &str) -> String {
189 let glyph = sf_symbol_emoji(name);
190 let s = size.max(12.0);
191 let fs = s * 0.72;
192 format!(
193 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{s}" height="{s}" viewBox="0 0 {s} {s}">
194 <text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle"
195 font-size="{fs}" fill="{color}">{glyph}</text>
196</svg>"##
197 )
198}
199
200#[cfg(feature = "rasterize")]
202pub fn icon_png_data_uri(name: &str, size: f64, color: &str) -> Result<String, String> {
203 crate::rasterize::svg_to_data_uri(&icon_svg(name, size, color))
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn heart_maps_to_favorite() {
212 let r = resolve_sf_symbol("heart.fill");
213 assert_eq!(r.material, "favorite");
214 assert_ne!(r.emoji, "h");
215 }
216
217 #[test]
218 fn unknown_not_first_letter() {
219 let r = resolve_sf_symbol("totally.unknown.glyph");
220 assert_eq!(r.emoji, "•");
221 assert!(!r.material.is_empty());
222 }
223}