1use std::collections::HashMap;
21use std::sync::{Arc, OnceLock, RwLock};
22
23use rosace_core::types::{Point, Rect, Size};
24use rosace_render::{Color, OwnedFace};
25use super::{Widget, LayoutCtx, PaintCtx};
26
27const ICON_FONT_BYTES: &[u8] =
34 include_bytes!("../../assets/MaterialSymbolsOutlined.ttf");
35
36const ICON_CODEPOINTS: &str =
39 include_str!("../../assets/MaterialSymbolsOutlined.codepoints");
40
41fn icon_font() -> &'static Arc<OwnedFace> {
44 static FONT: OnceLock<Arc<OwnedFace>> = OnceLock::new();
45 FONT.get_or_init(|| {
46 Arc::new(
47 OwnedFace::from_bytes(ICON_FONT_BYTES)
48 .expect("bundled Material Symbols font parses"),
49 )
50 })
51}
52
53fn registry() -> &'static RwLock<HashMap<String, char>> {
56 static REG: OnceLock<RwLock<HashMap<String, char>>> = OnceLock::new();
57 REG.get_or_init(|| {
58 let mut map = HashMap::new();
59 for line in ICON_CODEPOINTS.lines() {
60 if let Some((name, hex)) = line.split_once(' ') {
61 if let Some(c) = u32::from_str_radix(hex.trim(), 16)
62 .ok()
63 .and_then(char::from_u32)
64 {
65 map.insert(name.to_string(), c);
66 }
67 }
68 }
69 RwLock::new(map)
70 })
71}
72
73pub fn register_icon(name: impl Into<String>, codepoint: char) {
82 registry()
83 .write()
84 .expect("icon registry poisoned")
85 .insert(name.into(), codepoint);
86}
87
88pub fn resolve_icon(name: &str) -> Option<char> {
91 registry()
92 .read()
93 .expect("icon registry poisoned")
94 .get(name)
95 .copied()
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum IconKind {
103 Check,
104 Close,
105 Add,
106 Remove,
107 Search,
108 Menu,
109 Arrow,
110 ChevronRight,
111 ChevronLeft,
112 ChevronDown,
113 ChevronUp,
114 Settings,
115 User,
116 Home,
117 Inbox,
118 Calendar,
119 Star,
120 Heart,
121 Bell,
122 Edit,
123 Trash,
124 Upload,
125 Download,
126 Filter,
127 Sort,
128 Grid,
129 List,
130 Circle,
131 Dot,
132}
133
134impl IconKind {
135 pub const ALL: [IconKind; 29] = [
137 IconKind::Check, IconKind::Close, IconKind::Add, IconKind::Remove,
138 IconKind::Search, IconKind::Menu, IconKind::Arrow,
139 IconKind::ChevronRight, IconKind::ChevronLeft, IconKind::ChevronDown,
140 IconKind::ChevronUp, IconKind::Settings,
141 IconKind::User, IconKind::Home, IconKind::Inbox, IconKind::Calendar,
142 IconKind::Star, IconKind::Heart, IconKind::Bell, IconKind::Edit,
143 IconKind::Trash, IconKind::Upload, IconKind::Download,
144 IconKind::Filter, IconKind::Sort, IconKind::Grid, IconKind::List,
145 IconKind::Circle, IconKind::Dot,
146 ];
147
148 fn codepoint(self) -> Option<char> {
158 Some(match self {
159 IconKind::Check => '\u{e668}', IconKind::Close => '\u{e5cd}', IconKind::Add => '\u{e145}', IconKind::Remove => '\u{e15b}', IconKind::Search => '\u{ef7a}', IconKind::Menu => '\u{e5d2}', IconKind::Arrow => '\u{e5c8}', IconKind::ChevronRight => '\u{e5cc}', IconKind::ChevronLeft => '\u{e5cb}', IconKind::ChevronDown => '\u{e5cf}', IconKind::ChevronUp => '\u{e5ce}', IconKind::Settings => '\u{e8b8}', IconKind::User => '\u{f0d3}', IconKind::Home => '\u{e9b2}', IconKind::Inbox => '\u{e156}', IconKind::Calendar => '\u{ebcc}', IconKind::Star => '\u{f09a}', IconKind::Heart => '\u{e87e}', IconKind::Bell => '\u{e7f5}', IconKind::Edit => '\u{f097}', IconKind::Trash => '\u{e92e}', IconKind::Upload => '\u{f09b}', IconKind::Download => '\u{f090}', IconKind::Filter => '\u{e152}', IconKind::Sort => '\u{e164}', IconKind::Grid => '\u{e9b0}', IconKind::List => '\u{e896}', IconKind::Circle | IconKind::Dot => return None,
187 })
188 }
189}
190
191enum IconSource {
193 Kind(IconKind),
194 Glyph(char),
195 Named(String),
196}
197
198pub struct Icon {
202 source: IconSource,
203 pub size: f32,
204 pub color: Color,
205}
206
207impl Icon {
208 pub fn new(kind: IconKind) -> Self {
210 Self::from_source(IconSource::Kind(kind))
211 }
212
213 pub fn glyph(codepoint: char) -> Self {
216 Self::from_source(IconSource::Glyph(codepoint))
217 }
218
219 pub fn named(name: impl Into<String>) -> Self {
224 Self::from_source(IconSource::Named(name.into()))
225 }
226
227 fn from_source(source: IconSource) -> Self {
228 Self { source, size: 16.0, color: Color::rgb(180, 184, 210) }
229 }
230
231 pub fn size(mut self, s: f32) -> Self { self.size = s; self }
232 pub fn color(mut self, c: Color) -> Self { self.color = c; self }
233}
234
235impl Widget for Icon {
236 fn layout(&self, _ctx: &LayoutCtx) -> Size {
237 Size { width: self.size, height: self.size }
238 }
239
240 fn paint(&self, ctx: &mut PaintCtx) {
241 let r = ctx.rect;
242 let cx = r.origin.x + r.size.width / 2.0;
243 let cy = r.origin.y + r.size.height / 2.0;
244 let s = self.size;
245 let c = self.color;
246
247 let glyph = match &self.source {
248 IconSource::Kind(k) => k.codepoint(),
249 IconSource::Glyph(g) => Some(*g),
250 IconSource::Named(n) => resolve_icon(n),
251 };
252
253 let Some(ch) = glyph else {
254 match &self.source {
255 IconSource::Kind(IconKind::Circle) => {
256 ctx.fill_circle(Point { x: cx, y: cy }, s * 0.4, c);
257 }
258 IconSource::Kind(IconKind::Dot) => {
259 ctx.fill_circle(Point { x: cx, y: cy }, s * 0.2, c);
260 }
261 _ => {
264 let half = s * 0.35;
265 ctx.stroke_rect(
266 Rect {
267 origin: Point { x: cx - half, y: cy - half },
268 size: Size { width: half * 2.0, height: half * 2.0 },
269 },
270 c,
271 1.0,
272 );
273 }
274 }
275 return;
276 };
277
278 if !ctx.font.has_icon_face() {
281 ctx.font.set_icon_face(Arc::clone(icon_font()));
282 }
283
284 let m = ctx.font.glyph(ch, s).0;
291 let asc = ctx.font.ascender(s) as f32;
292 let origin = Point {
293 x: cx - m.width as f32 / 2.0 - m.xmin as f32,
294 y: cy - m.height as f32 / 2.0
295 - (asc - m.ymin as f32 - m.height as f32),
296 };
297 let mut buf = [0u8; 4];
298 ctx.draw_text_at(ch.encode_utf8(&mut buf), origin, c, s);
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use rosace_layout::Constraints;
306
307 #[test]
308 fn lays_out_at_requested_size() {
309 let font = rosace_render::FontCache::embedded();
310 let theme = rosace_theme::built_in::dark_theme();
311 let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
312 let size = Icon::new(IconKind::Star).size(24.0).layout(&ctx);
313 assert_eq!(size.width, 24.0);
314 assert_eq!(size.height, 24.0);
315 }
316
317 #[test]
318 fn bundled_font_parses_and_covers_every_mapped_kind() {
319 let cache = rosace_render::FontCache::embedded();
324 cache.set_icon_face(Arc::clone(icon_font()));
325 for kind in IconKind::ALL {
326 let Some(cp) = kind.codepoint() else { continue };
327 let (metrics, bitmap) = cache.rasterize(cp, 24.0);
328 assert!(
329 metrics.width > 0 && bitmap.iter().any(|&b| b > 0),
330 "{kind:?} ({cp:?}) rasterized empty — missing from the bundled icon font or a real rendering regression"
331 );
332 }
333 }
334
335 #[test]
336 fn downstream_crates_register_custom_icons_by_name() {
337 assert_eq!(resolve_icon("acme_rocket"), None);
340 register_icon("acme_rocket", '\u{eb9b}'); assert_eq!(resolve_icon("acme_rocket"), Some('\u{eb9b}'));
342 let _widget = Icon::named("acme_rocket").size(24.0);
343 }
344
345 #[test]
346 fn material_names_are_preregistered_from_the_codepoints_table() {
347 assert_eq!(resolve_icon("search"), Some('\u{ef7a}'));
348 assert_eq!(resolve_icon("wifi"), Some('\u{e63e}'));
349 assert_eq!(resolve_icon("not_a_real_icon_name"), None);
350 }
351
352 #[test]
353 fn font_cache_routes_icon_codepoints_to_the_icon_face() {
354 let cache = rosace_render::FontCache::embedded();
355 cache.set_icon_face(Arc::clone(icon_font()));
356 let glyph = cache.glyph('\u{ef7a}', 24.0);
360 assert!(glyph.0.width > 0, "icon glyph resolved to tofu");
361 assert!(glyph.1.iter().any(|&b| b > 0), "icon glyph has no coverage");
362 }
363}