Skip to main content

tauri_plugin_widgets/
icons.rs

1//! SF Symbol → cross-platform icon resolve (Material ligature + emoji fallback).
2//!
3//! Not SF Symbols outside Apple — similar glyphs only. Unknown names never fall
4//! back to a random first letter (that looked broken); use a neutral bullet.
5
6use std::sync::OnceLock;
7
8/// Resolved icon for non-Apple hosts.
9#[derive(Debug, Clone, Copy)]
10pub struct IconRef {
11    /// Material Symbols ligature / icon name (e.g. `favorite`).
12    pub material: &'static str,
13    /// Emoji / unicode glyph fallback when a font is unavailable.
14    pub emoji: &'static str,
15}
16
17/// Resolve an SF Symbol-ish name to a Material + emoji pair.
18pub 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    // Heuristic: cloud.sun.fill → cloud_sun / favorite-style material names.
24    let material = heuristic_material(&n);
25    IconRef {
26        material: intern_material(material),
27        emoji: "•",
28    }
29}
30
31/// Emoji/glyph used by Adaptive Cards TextBlock and Desktop when no PNG bake.
32pub fn sf_symbol_emoji(name: &str) -> &'static str {
33    resolve_sf_symbol(name).emoji
34}
35
36/// Material Symbols ligature name.
37pub 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    // Leak rare heuristic strings so IconRef stays 'static (widgets are long-lived).
60    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
75/// Curated SF → Material Symbols + emoji. Expand as presets need.
76const 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
187/// Tiny SVG that draws the emoji/glyph centered (works with resvg for Windows Board).
188pub 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/// Rasterize an SF-ish name to a PNG `data:` URI (`rasterize` feature).
201#[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}