Skip to main content

azul_core/
icon.rs

1//! Generic icon provider system for Azul
2//!
3//! This module defines a generic, callback-based icon resolution infrastructure.
4//! The actual parsing/loading implementations live in `azul-layout`.
5//!
6//! # Architecture
7//!
8//! The icon system is fully generic using RefAny:
9//!
10//! 1. `IconProviderHandle` - stores icons in nested map: pack_name → (icon_name → RefAny)
11//! 2. The resolver callback turns (icon_data, original_dom) into a StyledDom
12//! 3. Differentiation between Image/Font/SVG/etc. is via RefAny::downcast
13//! 4. Supports any icon source: images, fonts, SVGs, animated icons, etc.
14//!
15//! # Resolution Flow
16//!
17//! 1. User creates Icon nodes: `Dom::create_icon("home")`
18//! 2. Before layout, `resolve_icons_in_styled_dom()` is called
19//! 3. Each Icon node is looked up across all packs (first match wins)
20//! 4. The resolver callback is invoked with the found RefAny data + original DOM
21//! 5. The callback returns a StyledDom subtree that replaces the icon node
22//!
23//! # Caching
24//!
25//! Resolution results are CACHED on the [`SharedIconProvider`], keyed by
26//! (icon spec, the original icon node's full `NodeData`, its `StyledNode`),
27//! and flushed when the `SystemStyle` changes. The engine calls
28//! `resolve_icons_in_styled_dom` on EVERY DOM regeneration — during a Wayland
29//! drag-resize that is one call per pixel of mouse movement (373 in a measured
30//! 5-second drag), and each un-cached resolution runs `StyledDom::create`'s
31//! full single-node cascade whose output is then thrown away by the host's
32//! own cascade recompute. ~66 ribbon icons × 373 regenerations ≈ 24 600
33//! throwaway cascades per drag, all yielding bit-identical results
34//!.
35//!
36//! The cache stores the resolver's output DECONSTRUCTED into exactly the
37//! fields the replacement consumes (node type, inline style, accessibility,
38//! styled node), so a hit is four field clones — no `Dom`, no `StyledDom`,
39//! no cascade, no `CssPropertyCache`, not even the single-node extraction of
40//! the original.
41//!
42//! Correctness notes:
43//! - The KEY includes the whole original `NodeData` + `StyledNode`, because a
44//!   custom resolver may read anything from `original_icon_dom` (the default
45//!   one copies inline styles and accessibility info). Same name with
46//!   different inline styles → separate entries; a hover-state flip on the
47//!   node → different `StyledNode` → re-resolve.
48//! - The icon SET and the resolver are frozen once the provider is shared
49//!   (`App::run` consumes the handle; `SharedIconProvider` exposes no
50//!   registration), so registration invalidation cannot be needed post-share.
51//! - "Animated icons" remain compatible: animation is carried by the DATA the
52//!   resolver returns (e.g. an image-callback node that animates per frame),
53//!   not by re-resolving per frame — re-resolution only ever happened on DOM
54//!   regeneration anyway.
55//!
56//! # Custom Resolvers
57//!
58//! Users can provide custom C callbacks for complete control:
59//!
60//! ```c
61//! AzStyledDom my_resolver(
62//!     AzRefAny* icon_data,           // NULL if icon not found
63//!     AzStyledDom* original_icon_dom, // Contains icon_name, styles, a11y
64//!     AzSystemStyle* system_style
65//! ) {
66//!     // Custom resolution logic - icon_data contains your registered data
67//!     return create_my_icon_dom(...);
68//! }
69//! ```
70
71use alloc::{
72    boxed::Box,
73    collections::BTreeMap,
74    string::{String, ToString},
75    sync::Arc,
76    vec::Vec,
77};
78use core::fmt;
79use core::mem::ManuallyDrop;
80
81#[cfg(feature = "std")]
82use std::sync::Mutex;
83
84#[cfg(not(feature = "std"))]
85use self::nostd_lock::Mutex;
86
87/// Minimal `no_std` spinlock that mirrors the slice of the `std::sync::Mutex`
88/// API actually used by this module (`new` + `lock` returning a `Result`).
89#[cfg(not(feature = "std"))]
90mod nostd_lock {
91    use core::cell::UnsafeCell;
92    use core::ops::{Deref, DerefMut};
93    use core::sync::atomic::{AtomicBool, Ordering};
94
95    pub struct Mutex<T> {
96        locked: AtomicBool,
97        data: UnsafeCell<T>,
98    }
99
100    unsafe impl<T: Send> Send for Mutex<T> {}
101    unsafe impl<T: Send> Sync for Mutex<T> {}
102
103    pub struct MutexGuard<'a, T> {
104        lock: &'a Mutex<T>,
105    }
106
107    impl<T> Mutex<T> {
108        pub fn new(data: T) -> Self {
109            Mutex {
110                locked: AtomicBool::new(false),
111                data: UnsafeCell::new(data),
112            }
113        }
114
115        /// Returns `Ok(guard)` to mirror `std::sync::Mutex::lock`. Never poisons.
116        pub fn lock(&self) -> Result<MutexGuard<'_, T>, core::convert::Infallible> {
117            while self
118                .locked
119                .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
120                .is_err()
121            {
122                core::hint::spin_loop();
123            }
124            Ok(MutexGuard { lock: self })
125        }
126    }
127
128    impl<'a, T> Deref for MutexGuard<'a, T> {
129        type Target = T;
130        fn deref(&self) -> &T {
131            unsafe { &*self.lock.data.get() }
132        }
133    }
134
135    impl<'a, T> DerefMut for MutexGuard<'a, T> {
136        fn deref_mut(&mut self) -> &mut T {
137            unsafe { &mut *self.lock.data.get() }
138        }
139    }
140
141    impl<'a, T> Drop for MutexGuard<'a, T> {
142        fn drop(&mut self) {
143            self.lock.locked.store(false, Ordering::Release);
144        }
145    }
146
147    // Mirror `std::sync::Mutex: Debug` so containers can derive Debug. Does not
148    // lock (the spinlock has no `try_lock`, and locking in `fmt` could deadlock).
149    impl<T> core::fmt::Debug for Mutex<T> {
150        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
151            f.debug_struct("Mutex").finish_non_exhaustive()
152        }
153    }
154}
155
156use azul_css::{system::SystemStyle, AzString};
157
158use crate::{
159    dom::{Dom, NodeData, NodeType},
160    refany::{OptionRefAny, RefAny},
161    styled_dom::StyledDom,
162};
163
164// Type name constants for RefAny-based icon type detection in debug output
165const IMAGE_ICON_DATA_TYPE_NAME: &str = "ImageIconData";
166const FONT_ICON_DATA_TYPE_NAME: &str = "FontIconData";
167
168// Icon Resolver Callback
169
170/// Callback type for resolving icon data to a `StyledDom`.
171///
172/// Parameters:
173/// - `icon_data`: The `RefAny` data from the icon pack (cloned, or None if not found)
174/// - `original_icon_dom`: The original icon node's `StyledDom` (contains inline styles, a11y info, `icon_name`)
175/// - `system_style`: Current system style (theme, colors, etc.)
176///
177/// Returns: A `StyledDom` that will replace the icon node.
178/// The resolver should copy relevant styles from `original_icon_dom` to the result.
179/// Return an empty `StyledDom` to show a placeholder or nothing.
180///
181/// Note: `icon_name` is accessible via `original_icon_dom.node_data[0].get_node_type()` → `NodeType::Icon(name)`
182pub type IconResolverCallbackType = extern "C" fn(
183    icon_data: OptionRefAny,
184    original_icon_node: &NodeData,
185    system_style: &SystemStyle,
186) -> Dom;
187
188/// Default resolver: an empty div, i.e. the icon renders as nothing.
189#[must_use]
190pub extern "C" fn default_icon_resolver(
191    _icon_data: OptionRefAny,
192    _original_icon_node: &NodeData,
193    _system_style: &SystemStyle,
194) -> Dom {
195    Dom::create_div()
196}
197
198// Icon Provider Inner (single mutex)
199
200/// Inner data for `IconProviderHandle` - all fields behind single mutex
201#[derive(Debug, Clone)]
202pub struct IconProviderInner {
203    /// Nested map: `pack_name` → (`icon_name` → `RefAny`)
204    /// Differentiation between Image/Font/SVG is via `RefAny::downcast`
205    pub icons: BTreeMap<String, BTreeMap<String, RefAny>>,
206    /// The resolver callback
207    pub resolver: IconResolverCallbackType,
208}
209
210impl Default for IconProviderInner {
211    fn default() -> Self {
212        Self {
213            icons: BTreeMap::new(),
214            resolver: default_icon_resolver,
215        }
216    }
217}
218
219// Icon Provider Handle
220
221/// Icon provider stored in `AppConfig`.
222///
223/// This is a Box<IconProviderInner> for C FFI compatibility.
224/// When `App::run()` is called, it gets converted to Arc<Mutex<IconProviderInner>>
225/// and cloned to each window.
226///
227/// Icons are stored in a nested map: `pack_name` → (`icon_name` → `RefAny`)
228/// This allows:
229/// - Multiple packs with different sources (app-images, material-icons, etc.)
230/// - Easy unregistration of entire packs
231/// - First-match-wins lookup across all packs
232#[repr(C)]
233pub struct IconProviderHandle {
234    /// Boxed inner data - Box<T> is repr(C) compatible (single pointer).
235    /// `ManuallyDrop` so the Box is freed ONLY by our `Drop` (gated on
236    /// `run_destructor`), never by drop-glue. The codegen Az wrapper nests an
237    /// `AzIconProviderHandle` field (in `AzAppConfig`) whose own `Drop` re-runs
238    /// `_delete` -> `drop_in_place::<IconProviderHandle>` on the SAME bytes; with
239    /// a bare `Box` the glue freed it a second time -> double free. Same
240    /// convention as `GlContextPtr` / `CssPropertyCachePtr`.
241    pub inner: ManuallyDrop<Box<IconProviderInner>>,
242    pub run_destructor: bool,
243}
244
245impl Clone for IconProviderHandle {
246    fn clone(&self) -> Self {
247        Self {
248            inner: ManuallyDrop::new(Box::new((**self.inner).clone())),
249            run_destructor: true,
250        }
251    }
252}
253
254impl Drop for IconProviderHandle {
255    fn drop(&mut self) {
256        // First drop (run_destructor still true) frees the Box and clears the flag
257        // in the shared bytes; the codegen's redundant second drop sees false -> no-op.
258        if self.run_destructor {
259            self.run_destructor = false;
260            unsafe {
261                ManuallyDrop::drop(&mut self.inner);
262            }
263        }
264    }
265}
266
267impl fmt::Debug for IconProviderHandle {
268    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269        let pack_count = self.inner.icons.len();
270        let icon_count: usize = self.inner.icons.values().map(BTreeMap::len).sum();
271
272        f.debug_struct("IconProviderHandle")
273            .field("pack_count", &pack_count)
274            .field("icon_count", &icon_count)
275            .finish_non_exhaustive()
276    }
277}
278
279impl Default for IconProviderHandle {
280    fn default() -> Self {
281        Self::new()
282    }
283}
284
285impl IconProviderInner {
286    /// Resolves an icon SPEC to registered icon data.
287    ///
288    /// A spec is a comma-separated fallback list of entries, each either a
289    /// bare icon name (`"content_copy"`, searched across all packs in
290    /// registration order, first match wins) or a pack-qualified name
291    /// (`"material-icons:save"`, searched only in that pack). The first
292    /// entry that resolves wins, so markup can express per-platform
293    /// fallbacks: `<icon>ios:open_menu,kde:three-lines,menu</icon>`.
294    /// Icon names are case-insensitive; pack names are case-sensitive.
295    #[must_use]
296    pub fn lookup_spec(&self, spec: &str) -> Option<RefAny> {
297        // Verbatim first: a registered name is always found as-is (names may
298        // legally contain ':', ',' or whitespace). The spec syntax below only
299        // applies when nothing is registered under the literal name.
300        let verbatim = spec.to_lowercase();
301        if let Some(data) = self.icons.values().find_map(|pack| pack.get(&verbatim)) {
302            return Some(data.clone());
303        }
304
305        for entry in spec.split(',') {
306            let entry = entry.trim();
307            if entry.is_empty() {
308                continue;
309            }
310            let (pack, name) = match entry.split_once(':') {
311                Some((p, n)) => (Some(p.trim()), n.trim()),
312                None => (None, entry),
313            };
314            let name_lower = name.to_lowercase();
315            let found = pack.map_or_else(
316                || self.icons.values().find_map(|pack| pack.get(&name_lower)),
317                |p| self.icons.get(p).and_then(|pack| pack.get(&name_lower)),
318            );
319            if let Some(data) = found {
320                return Some(data.clone());
321            }
322        }
323        None
324    }
325}
326
327impl IconProviderHandle {
328    /// Create a new empty icon provider with the default (no-op) resolver.
329    ///
330    /// Note: The default resolver in core crate returns an empty `StyledDom`.
331    /// Use `set_resolver()` to set a proper resolver from the layout crate,
332    /// or use `with_resolver()` to create with a custom resolver.
333    #[must_use]
334    pub fn new() -> Self {
335        Self {
336            inner: ManuallyDrop::new(Box::new(IconProviderInner {
337                icons: BTreeMap::new(),
338                resolver: default_icon_resolver,
339            })),
340            run_destructor: true,
341        }
342    }
343
344    /// Create with a custom resolver callback
345    pub fn with_resolver(resolver: IconResolverCallbackType) -> Self {
346        Self {
347            inner: ManuallyDrop::new(Box::new(IconProviderInner {
348                icons: BTreeMap::new(),
349                resolver,
350            })),
351            run_destructor: true,
352        }
353    }
354
355    /// Convert this handle into an Arc<Mutex<IconProviderInner>> for use in windows.
356    ///
357    /// This consumes the Box and creates an Arc. Called by `App::run()` to create
358    /// the shared icon provider that gets cloned to each window.
359    pub(crate) fn into_shared(mut self) -> Arc<Mutex<IconProviderInner>> {
360        // Take the Box out and disarm our Drop so it doesn't free the moved-out
361        // allocation (ManuallyDrop::take leaves `inner` logically uninitialized).
362        let inner = unsafe { ManuallyDrop::take(&mut self.inner) };
363        self.run_destructor = false;
364        Arc::new(Mutex::new(*inner))
365    }
366
367    /// Set the resolver callback
368    pub fn set_resolver(&mut self, resolver: IconResolverCallbackType) {
369        self.inner.resolver = resolver;
370    }
371
372    /// Register a single icon in a pack (creates pack if needed).
373    ///
374    /// Note: `pack_name` is case-sensitive, while `icon_name` is normalized to lowercase.
375    pub fn register_icon(&mut self, pack_name: &str, icon_name: &str, data: RefAny) {
376        let pack = self.inner.icons.entry(pack_name.to_string()).or_default();
377        pack.insert(icon_name.to_lowercase(), data);
378    }
379
380    /// Unregister a single icon from a pack
381    pub fn unregister_icon(&mut self, pack_name: &str, icon_name: &str) {
382        if let Some(pack) = self.inner.icons.get_mut(pack_name) {
383            pack.remove(&icon_name.to_lowercase());
384            if pack.is_empty() {
385                self.inner.icons.remove(pack_name);
386            }
387        }
388    }
389
390    /// Unregister an entire icon pack
391    pub fn unregister_pack(&mut self, pack_name: &str) {
392        self.inner.icons.remove(pack_name);
393    }
394
395    /// Look up an icon across all packs, returning the pack name and data reference (first match wins)
396    fn lookup_with_pack(&self, icon_name: &str) -> Option<(&str, &RefAny)> {
397        let icon_name_lower = icon_name.to_lowercase();
398        for (pack_name, pack) in &self.inner.icons {
399            if let Some(data) = pack.get(&icon_name_lower) {
400                return Some((pack_name.as_str(), data));
401            }
402        }
403        None
404    }
405
406    /// Look up an icon by spec (bare name, `pack:name`, or a comma-separated
407    /// fallback list of either form; first match wins).
408    #[must_use]
409    pub fn lookup(&self, icon_name: &str) -> Option<RefAny> {
410        self.inner.lookup_spec(icon_name)
411    }
412
413    /// Check if an icon spec resolves in any pack
414    #[must_use]
415    pub fn has_icon(&self, icon_name: &str) -> bool {
416        self.inner.lookup_spec(icon_name).is_some()
417    }
418
419    /// List all pack names
420    #[must_use]
421    pub fn list_packs(&self) -> Vec<String> {
422        self.inner.icons.keys().cloned().collect()
423    }
424
425    /// List all icon names in a specific pack
426    #[must_use]
427    pub fn list_icons_in_pack(&self, pack_name: &str) -> Vec<String> {
428        self.inner
429            .icons
430            .get(pack_name)
431            .map(|pack| pack.keys().cloned().collect())
432            .unwrap_or_default()
433    }
434
435    /// Debug lookup: returns detailed info about an icon's `RefAny` contents
436    #[allow(clippy::used_underscore_binding)] // intentional `_`-prefix (FFI/api.json pub field, or cfg-gated binding); access is deliberate
437    #[must_use]
438    pub fn debug_lookup(&self, icon_name: &str) -> AzString {
439        use core::fmt::Write;
440
441        let icon_name_lower = icon_name.to_lowercase();
442
443        let mut result =
444            format!("Debug lookup for icon '{icon_name}' (normalized: '{icon_name_lower}'):\n");
445
446        // Report registered packs
447        let _ = writeln!(result, "  Total packs: {}", self.inner.icons.len());
448        for (pack_name, pack) in &self.inner.icons {
449            let _ = writeln!(result, "    Pack '{}': {} icons", pack_name, pack.len());
450            for name in pack.keys() {
451                let _ = writeln!(result, "      - {name}");
452            }
453        }
454
455        // Find the icon using shared lookup helper
456        match self.lookup_with_pack(icon_name) {
457            Some((pack, data)) => {
458                let _ = writeln!(result, "\n  FOUND in pack '{pack}'");
459                let type_name = data.get_type_name();
460                let _ = writeln!(result, "  RefAny type_name: '{}'", type_name.as_str());
461
462                let debug_info = data.sharing_info.debug_get_refcount_copied();
463                let _ = writeln!(
464                    result,
465                    "  RefAny size: {} bytes",
466                    debug_info._internal_layout_size
467                );
468
469                let type_str = type_name.as_str();
470                if type_str.contains(IMAGE_ICON_DATA_TYPE_NAME) {
471                    result.push_str("  RefAny type: ImageIconData (image-based icon)\n");
472                } else if type_str.contains(FONT_ICON_DATA_TYPE_NAME) {
473                    result.push_str("  RefAny type: FontIconData (font-based icon)\n");
474                } else {
475                    let _ = writeln!(result, "  RefAny type: UNKNOWN ('{type_str}')");
476                }
477            }
478            None => {
479                result.push_str("\n  NOT FOUND in any pack\n");
480            }
481        }
482
483        AzString::from(result)
484    }
485}
486
487/// Thread-safe icon provider for use in windows.
488///
489/// This is created from `IconProviderHandle::into_shared()` in `App::run()`
490/// and cloned to each window.
491#[derive(Debug, Clone)]
492pub struct SharedIconProvider {
493    inner: Arc<Mutex<IconProviderInner>>,
494    /// Resolution cache — see the module-level `# Caching` section. Shared by
495    /// every clone of this provider (all windows), like `inner`.
496    cache: Arc<Mutex<IconResolutionCache>>,
497}
498
499/// Hard cap on cached resolutions. A frame's live icon set is typically a few
500/// dozen; the cap only matters when specs vary without bound (adversarial or
501/// generated names). Policy on overflow is FLUSH-ALL: the next frame re-fills
502/// with the live set, so a pathological producer degrades to today's uncached
503/// behaviour instead of growing without limit.
504const ICON_CACHE_CAP: usize = 512;
505
506/// One cached resolution. `original`/`original_styled` are the KEY (together
507/// with the spec, the map key one level up); `resolution` is the value.
508#[derive(Debug)]
509struct IconCacheEntry {
510    /// The icon node as it was BEFORE resolution. Two `<icon>` nodes with the
511    /// same spec but different inline styles resolve differently, so the node
512    /// itself is part of the key.
513    original: NodeData,
514    /// The resolved replacement, spliced in whole. A `Dom` rather than a
515    /// flattened single node: an icon may be an arbitrary styled subtree.
516    resolution: Dom,
517}
518
519/// See the module-level `# Caching` section.
520#[derive(Debug, Default)]
521struct IconResolutionCache {
522    /// The `SystemStyle` every entry was resolved under. A mismatch flushes:
523    /// resolvers read the style (theme, tint, grayscale), so entries from
524    /// another style are wrong, not merely stale.
525    system_style: Option<SystemStyle>,
526    /// spec → entries with that spec (usually exactly one; more when the same
527    /// icon name appears with different inline styles).
528    entries: BTreeMap<String, Vec<IconCacheEntry>>,
529    /// Total entry count across all specs (the map holds vecs, so `len()` of
530    /// the map alone cannot enforce [`ICON_CACHE_CAP`]).
531    total: usize,
532}
533
534impl SharedIconProvider {
535    /// Create from an `IconProviderHandle` (consumes the handle)
536    #[must_use]
537    pub fn from_handle(handle: IconProviderHandle) -> Self {
538        Self {
539            inner: handle.into_shared(),
540            cache: Arc::new(Mutex::new(IconResolutionCache::default())),
541        }
542    }
543
544    /// Register (or REPLACE) one icon on a live shared provider.
545    ///
546    /// The registration path that exists after startup. `IconProviderHandle`
547    /// is consumed by [`Self::from_handle`], so a pack built once at
548    /// `App::create` could never be refreshed - and it has to be: a pack whose
549    /// artwork depends on the OS theme (the desktop's own icons, tinted with
550    /// the palette) is WRONG the moment the theme flips, and re-reading it is
551    /// the only way to get the dark variant.
552    ///
553    /// Flushes the resolution cache: entries there hold the Dom the OLD
554    /// artwork resolved to, and serving those back would make the
555    /// re-registration invisible.
556    pub fn register_icon(&self, pack_name: &str, icon_name: &str, data: RefAny) {
557        if let Ok(mut inner) = self.inner.lock() {
558            let pack = inner.icons.entry(pack_name.to_string()).or_default();
559            pack.insert(icon_name.to_lowercase(), data);
560        }
561        if let Ok(mut cache) = self.cache.lock() {
562            cache.entries.clear();
563            cache.total = 0;
564            // The next batch re-validates against whatever style it carries.
565            cache.system_style = None;
566        }
567    }
568
569    /// Flush the cache if `system_style` differs from the one its entries
570    /// were resolved under. Called ONCE per `resolve_icons_in_styled_dom`
571    /// batch, not per icon, so the `SystemStyle` comparison is per-frame.
572    fn validate_cache_for_style(&self, system_style: &SystemStyle) {
573        let Ok(mut cache) = self.cache.lock() else {
574            return;
575        };
576        match &cache.system_style {
577            Some(cached) if cached == system_style => {}
578            _ => {
579                cache.entries.clear();
580                cache.total = 0;
581                cache.system_style = Some(system_style.clone());
582            }
583        }
584    }
585
586    /// Cache hit test. `None` = miss (resolve for real, then
587    /// [`Self::store_resolution`]).
588    fn cached_resolution(&self, spec: &str, node: &NodeData) -> Option<Dom> {
589        let cache = self.cache.lock().ok()?;
590        cache
591            .entries
592            .get(spec)?
593            .iter()
594            .find_map(|e| (e.original == *node).then(|| e.resolution.clone()))
595    }
596
597    /// Insert a freshly-resolved entry, flushing everything first if the cap
598    /// is reached (see [`ICON_CACHE_CAP`]).
599    fn store_resolution(&self, spec: &str, node: &NodeData, resolution: &Dom) {
600        let Ok(mut cache) = self.cache.lock() else {
601            return;
602        };
603        if cache.total >= ICON_CACHE_CAP {
604            cache.entries.clear();
605            cache.total = 0;
606        }
607        cache
608            .entries
609            .entry(spec.to_string())
610            .or_default()
611            .push(IconCacheEntry {
612                original: node.clone(),
613                resolution: resolution.clone(),
614            });
615        cache.total += 1;
616    }
617
618    /// Resolve an icon to a `StyledDom` using the registered callback
619    #[must_use]
620    pub fn resolve(
621        &self,
622        original_icon_node: &NodeData,
623        icon_name: &str,
624        system_style: &SystemStyle,
625    ) -> Dom {
626        let (resolver, lookup_result) = {
627            let Ok(guard) = self.inner.lock() else {
628                return Dom::create_div();
629            };
630
631            let resolver = guard.resolver;
632            let lookup_result = guard.lookup_spec(icon_name);
633
634            (resolver, lookup_result)
635        };
636
637        resolver(lookup_result.into(), original_icon_node, system_style)
638    }
639
640    /// [`Self::resolve`], memoised on `(spec, icon node)`.
641    ///
642    /// The system style is not part of the key: a change to it clears the whole
643    /// cache once per pass (`validate_cache_for_style`), which is cheaper than
644    /// carrying it in every entry.
645    #[must_use]
646    fn resolve_cached(
647        &self,
648        original_icon_node: &NodeData,
649        icon_name: &str,
650        system_style: &SystemStyle,
651    ) -> Dom {
652        if let Some(hit) = self.cached_resolution(icon_name, original_icon_node) {
653            return hit;
654        }
655        let resolved = self.resolve(original_icon_node, icon_name, system_style);
656        self.store_resolution(icon_name, original_icon_node, &resolved);
657        resolved
658    }
659
660    /// Look up an icon by spec (bare name, `pack:name`, or a comma-separated
661    /// fallback list of either form; first match wins)
662    #[must_use]
663    pub fn lookup(&self, icon_name: &str) -> Option<RefAny> {
664        self.inner
665            .lock()
666            .ok()
667            .and_then(|guard| guard.lookup_spec(icon_name))
668    }
669
670    /// Check if an icon spec resolves
671    #[must_use]
672    pub fn has_icon(&self, icon_name: &str) -> bool {
673        self.inner
674            .lock()
675            .map(|guard| guard.lookup_spec(icon_name).is_some())
676            .unwrap_or(false)
677    }
678}
679
680// Icon Resolution in the Dom tree
681
682/// How many times an icon may resolve to another icon before we stop.
683///
684/// Chains are legitimate - restyling an existing icon by registering a `Dom`
685/// that contains it is the obvious way to do it - but a resolver is user code,
686/// so a cycle has to terminate. Direct self-reference is caught exactly; this
687/// bounds everything longer.
688const MAX_ICON_INDIRECTION: usize = 8;
689
690/// Replace every `NodeType::Icon` node in `dom` with whatever the registered
691/// resolver returns for it.
692///
693/// # Why this runs on a `Dom`, BEFORE the cascade
694///
695/// This used to run on a `StyledDom`, after the cascade, and it is worth
696/// recording why that was wrong - the shape of the old code is still visible in
697/// the git history and in several comments elsewhere.
698///
699/// A `StyledDom` is a FLAT ARENA in DFS order: a node's first child is the next
700/// index. So a replacement's children could not be attached to the icon node
701/// after the fact without inserting mid-arena and shifting every index after
702/// them. The old code therefore flattened every replacement down to its ROOT
703/// node's `node_type` / `style` / `accessibility` plus a single glyph character
704/// threaded into a text leaf, and threw the rest away - including the whole
705/// `CssPropertyCache` that the resolver's own cascade had just built. Its own
706/// comment said so: "everything else in the returned `StyledDom` ... was always
707/// discarded".
708///
709/// That cost three things:
710///
711/// * **A wasted cascade per icon**, whose result was discarded.
712/// * **Any icon that is not one node was impossible.** Registering a styled
713///   `Dom` as an icon could not work, because only the root survived.
714/// * **A stale property cache.** Rewriting a node's inline `style` after the
715///   cascade left the precomputed per-node arrays describing the PRE-resolution
716///   node. For a font icon that hid `font-family: StyleFontFamily::Ref(face)` -
717///   the only place that face is named - from font collection, so shaping fell
718///   back to a face with no glyph at the icon's private-use codepoint and drew
719///   `.notdef`. It needed an explicit cache rebuild to paper over.
720///
721/// Running on the `Dom` removes all three by construction. A `Dom` is a real
722/// tree (`root` + `children` + its own `css`), so a replacement is spliced whole;
723/// nothing is cascaded twice because the cascade has not happened yet; and there
724/// is no property cache to invalidate. An icon is now free to be an arbitrary
725/// styled subtree, which is what makes "register a `Dom` as an icon" work -
726/// including the colour it should be, which travels with the icon rather than
727/// having to be threaded through every call site as a tint parameter.
728pub fn resolve_icons_in_dom(
729    dom: &mut Dom,
730    provider: &SharedIconProvider,
731    system_style: &SystemStyle,
732) {
733    // A SystemStyle change (theme flip, tint, grayscale) invalidates every
734    // cached resolution. Checked once per pass, not once per icon.
735    provider.validate_cache_for_style(system_style);
736    resolve_icons_in_dom_inner(dom, provider, system_style);
737}
738
739/// Resolve every `<icon>` in a user `Dom` and cascade it - the two halves of
740/// "a `Dom` the application handed us becomes a `StyledDom`", as one call.
741///
742/// The halves were separate, and that is exactly how a path came to skip one:
743/// three call sites ran `resolve_icons_in_dom` and then
744/// `StyledDom::create_from_dom`, while a fourth - the DOM a VirtualView
745/// callback returns - ran only the cascade. An `<icon>` inside a virtual view
746/// therefore never resolved, and nothing downstream would ever resolve it
747/// later, so it stayed an empty node for the life of the view.
748///
749/// The order is not interchangeable and is not obvious from either name:
750/// resolution MUST precede the cascade, because a replacement is a SUBTREE and
751/// `StyledDom` is a flat arena in DFS order - splicing one in afterwards would
752/// mean inserting mid-arena and shifting every index after it, which is what
753/// used to flatten every icon down to its root node. Giving the pair a single
754/// name is what stops the next caller from re-deriving that.
755#[must_use]
756pub fn styled_dom_resolving_icons(
757    mut dom: Dom,
758    provider: &SharedIconProvider,
759    system_style: &SystemStyle,
760) -> StyledDom {
761    resolve_icons_in_dom(&mut dom, provider, system_style);
762    StyledDom::create_from_dom(dom)
763}
764
765/// The private dataset behind [`Dom::create_icon_view`]: the spec that view
766/// renders right now. Public because the swap API downcasts it - see
767/// `CallbackInfo::set_icon`.
768#[derive(Debug, Clone, PartialEq, Eq)]
769pub struct IconViewState {
770    /// An icon spec, i.e. a comma-separated fallback chain exactly as
771    /// `Dom::create_icon` takes ("system:titlebar-close,close").
772    pub spec: AzString,
773}
774
775/// The body of [`Dom::create_icon_view`], which is where this is documented.
776///
777/// Not public itself: the constructor is the API, and two spellings of one
778/// thing is how they drift.
779#[must_use]
780pub(crate) fn icon_view(spec: impl Into<AzString>) -> Dom {
781    let dataset = RefAny::new(IconViewState { spec: spec.into() });
782    Dom::create_virtual_view(
783        dataset.clone(),
784        crate::callbacks::VirtualViewCallback::create(render_icon_view),
785    )
786    // The SAME `RefAny` as the view's own payload, on the node: the swap API
787    // reaches it through `CallbackInfo::get_dataset`, and a clone points at
788    // the same data, so rewriting the spec here is what the callback reads
789    // there. (The progress bar's fast path is built the same way.)
790    .with_dataset(OptionRefAny::Some(dataset))
791    // Lays out like the icon node it stands in for. A `VirtualView` defaults
792    // to `display: block` (it exists to virtualize scrollable content) and to
793    // `overflow: auto` with it - but an icon is INLINE content, and one that
794    // grows a scrollbar is absurd. The view also reports the icon's MEASURED
795    // size, which can exceed a box the caller sized itself (a 40px icon asked
796    // to sit in a 24px button), and `auto` would answer that with a bar.
797    //
798    // A caller's own `with_css` is appended after this and so wins on anything
799    // it states; this only fills in what the caller has no reason to think
800    // about.
801    .with_css("display: inline-block; overflow: hidden;")
802}
803
804/// [`icon_view`]'s callback: render the spec the dataset currently holds.
805extern "C" fn render_icon_view(
806    mut data: RefAny,
807    info: crate::callbacks::VirtualViewCallbackInfo,
808) -> crate::callbacks::VirtualViewReturn {
809    use crate::geom::{LogicalPosition, LogicalRect, LogicalSize};
810
811    let spec = match data.downcast_ref::<IconViewState>() {
812        // Foreign payload: render nothing rather than lie about bounds.
813        None => return crate::callbacks::VirtualViewReturn::default(),
814        Some(state) => state.spec.clone(),
815    };
816    let dom = Dom::create_icon(spec);
817
818    // How big the icon actually is. `measure_dom` styles through the window,
819    // which resolves the icon first - so this measures the ARTWORK, not the
820    // empty `<icon>` node.
821    //
822    // Measured against the view's own box, which is what a replaced element's
823    // content is laid out in. An auto-sized view's box is the replaced-element
824    // default (300x150) on the first pass and the icon's own size afterwards;
825    // either is a box an icon fits in, so the measurement is the icon's
826    // natural size in both. A box of ZERO is the degenerate case - a view in a
827    // collapsed parent - where a real constraint would measure the icon to
828    // nothing.
829    let bounds = info.bounds.get_logical_size();
830    let available = if bounds.width > 0.0 && bounds.height > 0.0 {
831        bounds
832    } else {
833        LogicalSize::new(UNCONSTRAINED, UNCONSTRAINED)
834    };
835    let measured = info.measure_dom(dom.clone(), available);
836    // A measurement of zero means there was no measure hook (or nothing to
837    // draw); reporting it would collapse an auto-sized view to nothing.
838    let size = if measured.width > 0.0 && measured.height > 0.0 {
839        measured
840    } else {
841        bounds
842    };
843
844    let rect = LogicalRect::new(LogicalPosition::zero(), size);
845    // An icon does not scroll, so all three rects are the same box.
846    crate::callbacks::VirtualViewReturn::with_dom(dom, rect, rect)
847}
848
849/// The "no constraint" box an auto-sized icon is measured in. Large enough
850/// that no icon is wrapped or clipped by it, finite so a bug cannot turn into
851/// a NaN geometry.
852const UNCONSTRAINED: f32 = 4096.0;
853
854/// The recursive half of [`resolve_icons_in_dom`].
855fn resolve_icons_in_dom_inner(
856    dom: &mut Dom,
857    provider: &SharedIconProvider,
858    system_style: &SystemStyle,
859) {
860    // An icon may resolve TO another icon - registering
861    // `Dom::create_icon("favorite").with_css("color: red")` under another name
862    // is the natural way to restyle an existing icon - so this iterates rather
863    // than resolving once.
864    //
865    // Bounded two ways, because a resolver is user code and can trivially cycle:
866    // a resolution that yields the SAME spec is a self-reference and stops
867    // immediately, and any longer cycle stops at `MAX_ICON_INDIRECTION`. In both
868    // cases the node is left as-is rather than looping forever.
869    let mut seen = 0;
870    while let Some(spec) = icon_spec_of(dom) {
871        if seen >= MAX_ICON_INDIRECTION {
872            break;
873        }
874        let replacement = provider.resolve_cached(&dom.root, spec.as_str(), system_style);
875        if icon_spec_of(&replacement).as_ref().map(AzString::as_str) == Some(spec.as_str()) {
876            // Resolves to itself: replacing would spin.
877            break;
878        }
879        // The whole node is replaced, children included: an `<icon>name</icon>`
880        // carries its spec as a text child, and leaving it would render the raw
881        // spec next to the resolved icon.
882        //
883        // Its STYLESHEETS are carried forward, though. `Dom::with_css` attaches
884        // a scoped stylesheet to `Dom::css` rather than inline properties, so
885        // `Dom::create_icon("favorite").with_css("color: red")` - the natural
886        // way to register a recoloured icon - keeps the colour in `css`, not on
887        // the node. Dropping it with the node made the replacement render in the
888        // default colour and silently ignore the caller's styling.
889        //
890        // The replaced node's sheets go FIRST so the replacement's own
891        // declarations still win on conflict.
892        let mut css = dom.css.clone().into_library_owned_vec();
893        let mut replacement = replacement;
894        css.extend(replacement.css.clone().into_library_owned_vec());
895        replacement.css = css.into();
896        *dom = replacement;
897        seen += 1;
898    }
899
900    for child in dom.children.as_mut() {
901        resolve_icons_in_dom_inner(child, provider, system_style);
902    }
903}
904
905/// The icon spec for a node, or `None` if it is not an icon node.
906///
907/// An icon with an explicit non-empty name (`Dom::create_icon("x")`) uses it
908/// directly. One with an EMPTY name - the markup form `<icon>content_copy</icon>`,
909/// where the tag carries no name - derives the spec from its direct text
910/// children, exactly like a ligature icon font turns glyph text into an icon.
911fn icon_spec_of(dom: &Dom) -> Option<AzString> {
912    let NodeType::Icon(name) = dom.root.get_node_type() else {
913        return None;
914    };
915    let name = name.as_str();
916    if !name.is_empty() {
917        return Some(AzString::from(name));
918    }
919
920    let mut derived = alloc::string::String::new();
921    for child in dom.children.as_ref() {
922        if let NodeType::Text(t) = child.root.get_node_type() {
923            derived.push_str(t.as_str());
924        }
925    }
926    let derived = derived.trim();
927    if derived.is_empty() {
928        None
929    } else {
930        Some(AzString::from(derived))
931    }
932}
933
934// FFI Option Types
935
936impl_option!(IconProviderHandle, OptionIconProviderHandle, [Clone]);
937
938#[cfg(test)]
939#[path = "icon_test.rs"]
940mod icon_test;