Skip to main content

rosace_widgets/tree/
icon.rs

1//! Icon widget backed by the bundled Material Symbols Outlined font
2//! (D115/Phase 32 Step 2).
3//!
4//! Icons are font glyphs rendered through the ordinary text pipeline: the
5//! icon face is registered on the app's `FontCache` as an in-memory
6//! fallback ([`rosace_render::FontCache::set_icon_face`]), so a plain
7//! `DrawText` command carries each icon — physical-pixel rasterization on
8//! HiDPI and the Phase 27 GPU glyph atlas come for free. (The alternative —
9//! rasterizing here and blitting via `BlitRgba` — was rejected: blits are
10//! recorded in logical pixels and bilinearly rescaled on Retina, i.e.
11//! blurry.)
12//!
13//! Extensibility (the D115 exit bar): downstream crates call
14//! [`register_icon`] to bind a name to any codepoint in the icon font and
15//! render it with [`Icon::named`], or use [`Icon::glyph`] directly —
16//! no edits to `rosace-widgets` required. Every Material Symbols name is
17//! pre-registered from the bundled `.codepoints` table, so
18//! `Icon::named("wifi")` works out of the box.
19
20use 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
27/// Bundled Material Symbols Outlined variable font (Apache 2.0 — see
28/// `assets/LICENSE-MaterialSymbols.txt`). `OwnedFace::from_bytes` instances
29/// its default `wght: 400` (FILL 0, standard outlined style) via the real
30/// variable-font support the fontdue -> swash migration (D127, 2026-08-03)
31/// added — previously (fontdue) this rendered whatever un-instanced default
32/// the font happened to store, not a real explicit weight.
33const ICON_FONT_BYTES: &[u8] =
34    include_bytes!("../../assets/MaterialSymbolsOutlined.ttf");
35
36/// The `name codepoint` table shipped alongside the font — seeds the
37/// registry so every Material Symbols name resolves without registration.
38const ICON_CODEPOINTS: &str =
39    include_str!("../../assets/MaterialSymbolsOutlined.codepoints");
40
41/// The parsed icon face, shared process-wide (parsed once — the variable
42/// font is ~10 MB; every `FontCache` gets a clone of this one `Arc`).
43fn 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
53/// name → codepoint registry, pre-seeded with the full Material Symbols
54/// table on first use.
55fn 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
73/// Register (or override) a named icon codepoint — the D115 extension
74/// point. A downstream crate binds a name to any glyph in the icon font
75/// and renders it with [`Icon::named`], without editing `rosace-widgets`:
76///
77/// ```ignore
78/// rosace_widgets::register_icon("acme_logo", '\u{f0d3}');
79/// Icon::named("acme_logo").size(24.0)
80/// ```
81pub 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
88/// Resolve a registered icon name to its codepoint. All Material Symbols
89/// names (from the bundled `.codepoints` table) are pre-registered.
90pub fn resolve_icon(name: &str) -> Option<char> {
91    registry()
92        .read()
93        .expect("icon registry poisoned")
94        .get(name)
95        .copied()
96}
97
98/// Built-in icon names (backward compatible — D115's Migration Rule keeps
99/// every pre-Phase-32 variant working; they now render Material Symbols
100/// glyphs instead of the old hand-drawn primitive approximations).
101#[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    /// Every variant, in declaration order — for galleries and tests.
136    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    /// The Material Symbols codepoint this kind renders as (looked up in
149    /// the bundled `.codepoints` table — this variable font's assignments
150    /// differ from the legacy Material Icons set).
151    ///
152    /// `None` for the two geometric kinds: `Circle`/`Dot` mean a *filled*
153    /// disc, but the variable font's default instance is FILL=0, where
154    /// `circle` and `fiber_manual_record` rasterize as hollow rings
155    /// (verified pixel-level). Those two keep their exact primitive
156    /// `fill_circle` rendering — no visual regression.
157    fn codepoint(self) -> Option<char> {
158        Some(match self {
159            IconKind::Check        => '\u{e668}', // check
160            IconKind::Close        => '\u{e5cd}', // close
161            IconKind::Add          => '\u{e145}', // add
162            IconKind::Remove       => '\u{e15b}', // remove
163            IconKind::Search       => '\u{ef7a}', // search
164            IconKind::Menu         => '\u{e5d2}', // menu
165            IconKind::Arrow        => '\u{e5c8}', // arrow_forward
166            IconKind::ChevronRight => '\u{e5cc}', // chevron_right
167            IconKind::ChevronLeft  => '\u{e5cb}', // chevron_left
168            IconKind::ChevronDown  => '\u{e5cf}', // expand_more
169            IconKind::ChevronUp    => '\u{e5ce}', // expand_less
170            IconKind::Settings     => '\u{e8b8}', // settings
171            IconKind::User         => '\u{f0d3}', // person
172            IconKind::Home         => '\u{e9b2}', // home
173            IconKind::Inbox        => '\u{e156}', // inbox
174            IconKind::Calendar     => '\u{ebcc}', // calendar_month
175            IconKind::Star         => '\u{f09a}', // star
176            IconKind::Heart        => '\u{e87e}', // favorite
177            IconKind::Bell         => '\u{e7f5}', // notifications
178            IconKind::Edit         => '\u{f097}', // edit
179            IconKind::Trash        => '\u{e92e}', // delete
180            IconKind::Upload       => '\u{f09b}', // upload
181            IconKind::Download     => '\u{f090}', // download
182            IconKind::Filter       => '\u{e152}', // filter_list
183            IconKind::Sort         => '\u{e164}', // sort
184            IconKind::Grid         => '\u{e9b0}', // grid_view
185            IconKind::List         => '\u{e896}', // list
186            IconKind::Circle | IconKind::Dot => return None,
187        })
188    }
189}
190
191/// Where an [`Icon`]'s glyph comes from.
192enum IconSource {
193    Kind(IconKind),
194    Glyph(char),
195    Named(String),
196}
197
198/// A Material Symbols icon glyph rendered through the text pipeline at any
199/// size. `Circle`/`Dot` stay primitive-drawn filled discs (see
200/// [`IconKind::codepoint`]).
201pub struct Icon {
202    source: IconSource,
203    pub size: f32,
204    pub color: Color,
205}
206
207impl Icon {
208    /// A built-in icon.
209    pub fn new(kind: IconKind) -> Self {
210        Self::from_source(IconSource::Kind(kind))
211    }
212
213    /// Any codepoint from the icon font — for glyphs without a built-in
214    /// [`IconKind`] or registered name.
215    pub fn glyph(codepoint: char) -> Self {
216        Self::from_source(IconSource::Glyph(codepoint))
217    }
218
219    /// An icon by registered name ([`register_icon`]); all Material
220    /// Symbols names are pre-registered. Resolved at paint time, so
221    /// registration order doesn't matter; unresolved names paint a hollow
222    /// placeholder box.
223    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                // Unresolved name: hollow placeholder box (visible in dev,
262                // unlike silently painting nothing).
263                _ => {
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        // Route the icon face into this FontCache once (idempotent) so the
279        // DrawText below resolves the PUA codepoint to the icon font.
280        if !ctx.font.has_icon_face() {
281            ctx.font.set_icon_face(Arc::clone(icon_font()));
282        }
283
284        // Center the glyph in the icon box. `layout_glyphs` places a glyph's
285        // top-left at `origin + (xmin, ascender - ymin - height)`; solve for
286        // the text origin that puts the glyph's raster center on (cx, cy).
287        // Metrics here are at logical px; the canvas re-rasterizes at
288        // physical px, which scales linearly — placement agrees within a
289        // physical pixel.
290        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        // Routed through a real `FontCache` (matching the canvas text path)
320        // rather than calling the rasterizer directly — `OwnedFace` itself
321        // is intentionally a thin byte-owning wrapper with no glyph-lookup
322        // API of its own; `FontCache` is the one place that talks to swash.
323        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        // The D115 exit bar: a name NOT in the built-in set, bound to a
338        // codepoint without editing rosace-widgets.
339        assert_eq!(resolve_icon("acme_rocket"), None);
340        register_icon("acme_rocket", '\u{eb9b}'); // rocket_launch
341        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        // 'search' is a PUA codepoint DejaVu lacks — with the icon face
357        // installed it must rasterize with real coverage, through the same
358        // glyph API the canvas text path uses.
359        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}