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;