1
//! Default icon resolver implementations for Azul
2
//!
3
//! This module provides the standard callback implementations for icon resolution.
4
//! The core types and resolution infrastructure are in `azul_core::icon`.
5
//!
6
//! # Usage
7
//!
8
//! ```rust,ignore
9
//! use azul_core::icon::IconProviderHandle;
10
//! use azul_layout::icon::{default_icon_resolver, ImageIconData, FontIconData};
11
//!
12
//! // Create provider with the default resolver
13
//! let provider = IconProviderHandle::with_resolver(default_icon_resolver);
14
//!
15
//! // Register an image icon
16
//! provider.register_icon("app-images", "logo", RefAny::new(ImageIconData { 
17
//!     image: image_ref, width: 32.0, height: 32.0 
18
//! }));
19
//!
20
//! // Register a font icon
21
//! provider.register_icon("material-icons", "home", RefAny::new(FontIconData {
22
//!     font: font_ref, icon_char: "\u{e88a}".to_string()
23
//! }));
24
//! ```
25

            
26
use alloc::{
27
    string::{String, ToString},
28
    vec::Vec,
29
};
30

            
31
use azul_css::{
32
    system::SystemStyle,
33
    props::basic::{FontRef, StyleFontFamily, StyleFontFamilyVec},
34
    props::basic::length::FloatValue,
35
    props::layout::{LayoutWidth, LayoutHeight},
36
    props::property::CssProperty,
37
    props::style::filter::{StyleFilter, StyleFilterVec, StyleColorMatrix},
38
    props::style::text::StyleTextColor,
39
    dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec},
40
    css::{Css, CssPropertyValue},
41
};
42

            
43
use azul_core::{
44
    dom::{Dom, NodeData},
45
    icon::IconProviderHandle,
46
    refany::{OptionRefAny, RefAny},
47
    resources::ImageRef,
48
    styled_dom::StyledDom,
49
};
50

            
51
// ============================================================================
52
// Icon Data Marker Structs (for RefAny::downcast)
53
// ============================================================================
54

            
55
/// Image-based icon data stored in `RefAny` for the icon resolver.
56
///
57
/// Pass to `register_image_icon` or wrap in `RefAny::new(...)` and register
58
/// directly via `IconProviderHandle::register_icon`.
59
#[derive(Debug)]
60
pub struct ImageIconData {
61
    pub image: ImageRef,
62
    /// Width duplicated from `ImageRef` at registration time
63
    pub width: f32,
64
    /// Height duplicated from `ImageRef` at registration time
65
    pub height: f32,
66
}
67

            
68
/// Font-based icon data stored in `RefAny` for the icon resolver.
69
///
70
/// Pass to `register_font_icon` or wrap in `RefAny::new(...)` and register
71
/// directly via `IconProviderHandle::register_icon`.
72
#[derive(Debug)]
73
pub struct FontIconData {
74
    pub font: FontRef,
75
    /// The character/codepoint for this specific icon (e.g., "\u{e88a}" for home)
76
    pub icon_char: String,
77
}
78

            
79
// ============================================================================
80
// Default Icon Resolver
81
// ============================================================================
82

            
83
/// Default icon resolver that handles both image and font icons.
84
///
85
/// Resolution logic:
86
/// 1. If `icon_data` is None -> return empty div (icon not found)
87
/// 2. If `icon_data` contains `ImageIconData` -> render as image
88
/// 3. If `icon_data` contains `FontIconData` -> render as text with font
89
/// 4. Unknown data type -> return empty div
90
///
91
/// Styles from the original icon DOM are copied to the result,
92
/// filtered based on `SystemStyle` preferences.
93
39
#[must_use] pub extern "C" fn default_icon_resolver(
94
39
    icon_data: OptionRefAny,
95
39
    original_icon_dom: &StyledDom,
96
39
    system_style: &SystemStyle,
97
39
) -> StyledDom {
98
    // No icon found → empty div
99
39
    let Some(mut data) = icon_data.into_option() else {
100
3
        let mut dom = Dom::create_div();
101
3
        return StyledDom::create(&mut dom, Css::empty());
102
    };
103
    
104
    // Try ImageIconData
105
36
    if let Some(img) = data.downcast_ref::<ImageIconData>() {
106
13
        return create_image_icon_from_original(&img, original_icon_dom, system_style);
107
23
    }
108
    
109
    // Try FontIconData
110
23
    if let Some(font_icon) = data.downcast_ref::<FontIconData>() {
111
22
        return create_font_icon_from_original(&font_icon, original_icon_dom, system_style);
112
1
    }
113
    
114
    // Unknown data type → empty div
115
1
    let mut dom = Dom::create_div();
116
1
    StyledDom::create(&mut dom, Css::empty())
117
39
}
118

            
119
// Icon DOM Creation (from original)
120

            
121
/// Create a `StyledDom` for an image-based icon, copying styles from original.
122
///
123
/// Applies SystemStyle-aware modifications:
124
/// - Grayscale filter if `prefer_grayscale` is true
125
/// - Tint color overlay if `tint_color` is set
126
13
fn create_image_icon_from_original(
127
13
    img: &ImageIconData,
128
13
    original: &StyledDom,
129
13
    system_style: &SystemStyle,
130
13
) -> StyledDom {
131
13
    let mut dom = Dom::create_image(img.image.clone());
132
    
133
    // Copy appropriate styles from original
134
13
    if let Some(original_node) = original.node_data.as_ref().first() {
135
12
        let mut props_vec = copy_appropriate_styles_vec(original_node);
136
        
137
        // Add default dimensions if not specified in original styles
138
12
        let has_width = props_vec.iter().any(|p| matches!(&p.property, CssProperty::Width(_)));
139
12
        let has_height = props_vec.iter().any(|p| matches!(&p.property, CssProperty::Height(_)));
140
        
141
12
        if !has_width {
142
11
            props_vec.push(CssPropertyWithConditions::simple(
143
11
                CssProperty::width(LayoutWidth::px(img.width))
144
11
            ));
145
11
        }
146
12
        if !has_height {
147
11
            props_vec.push(CssPropertyWithConditions::simple(
148
11
                CssProperty::height(LayoutHeight::px(img.height))
149
11
            ));
150
11
        }
151
        
152
        // Apply SystemStyle-aware filters
153
12
        apply_icon_style_filters(&mut props_vec, system_style);
154
        
155
12
        dom.root.set_css_props(CssPropertyWithConditionsVec::from_vec(props_vec));
156
        
157
        // Copy accessibility info
158
12
        if let Some(a11y) = original_node.get_accessibility_info() {
159
1
            dom = dom.with_accessibility_info(a11y.clone());
160
11
        }
161
1
    } else {
162
1
        // No original node, use default dimensions
163
1
        let mut props_vec = vec![
164
1
            CssPropertyWithConditions::simple(CssProperty::width(LayoutWidth::px(img.width))),
165
1
            CssPropertyWithConditions::simple(CssProperty::height(LayoutHeight::px(img.height))),
166
1
        ];
167
1
        
168
1
        // Apply SystemStyle-aware filters even without original node
169
1
        apply_icon_style_filters(&mut props_vec, system_style);
170
1
        
171
1
        dom.root.set_css_props(CssPropertyWithConditionsVec::from_vec(props_vec));
172
1
    }
173
    
174
13
    StyledDom::create(&mut dom, Css::empty())
175
13
}
176

            
177
/// Create a `StyledDom` for a font-based icon, copying styles from original.
178
///
179
/// Applies SystemStyle-aware modifications:
180
/// - Text color override if `inherit_text_color` is true
181
/// - Tint color if `tint_color` is set
182
22
fn create_font_icon_from_original(
183
22
    font_icon: &FontIconData,
184
22
    original: &StyledDom,
185
22
    system_style: &SystemStyle,
186
22
) -> StyledDom {
187
22
    let mut dom = Dom::create_text_do_not_use_without_block_level_wrapper(font_icon.icon_char.clone());
188
    
189
    // Add font family
190
22
    let font_prop = CssPropertyWithConditions::simple(
191
22
        CssProperty::font_family(StyleFontFamilyVec::from_vec(vec![
192
22
            StyleFontFamily::Ref(font_icon.font.clone())
193
        ]))
194
    );
195
    
196
22
    if let Some(original_node) = original.node_data.as_ref().first() {
197
21
        let mut props_vec = copy_appropriate_styles_vec(original_node);
198
21
        props_vec.push(font_prop);
199
        
200
        // Apply SystemStyle-aware color modifications for font icons
201
21
        apply_font_icon_color(&mut props_vec, system_style);
202
        
203
21
        dom.root.set_css_props(CssPropertyWithConditionsVec::from_vec(props_vec));
204
        
205
        // Copy accessibility info
206
21
        if let Some(a11y) = original_node.get_accessibility_info() {
207
            dom = dom.with_accessibility_info(a11y.clone());
208
21
        }
209
1
    } else {
210
1
        // No original node, just set the font
211
1
        let mut props_vec = vec![font_prop];
212
1
        
213
1
        // Apply SystemStyle-aware color modifications
214
1
        apply_font_icon_color(&mut props_vec, system_style);
215
1
        
216
1
        dom.root.set_css_props(CssPropertyWithConditionsVec::from_vec(props_vec));
217
1
    }
218
    
219
22
    StyledDom::create(&mut dom, Css::empty())
220
22
}
221

            
222
/// Copy styles from original node
223
/// Returns a Vec for easier manipulation
224
36
fn copy_appropriate_styles_vec(
225
36
    original_node: &NodeData,
226
36
) -> Vec<CssPropertyWithConditions> {
227
    // Reconstruct the legacy flat list from the unified Css store.
228
36
    original_node
229
36
        .get_style()
230
36
        .iter_inline_properties()
231
36
        .map(|(prop, conds)| CssPropertyWithConditions {
232
516
            property: prop.clone(),
233
516
            apply_if: conds.clone(),
234
516
        })
235
36
        .collect()
236
36
}
237

            
238
/// Apply SystemStyle-aware filters to icon properties.
239
///
240
/// This adds CSS filters based on accessibility and theming settings:
241
/// - Grayscale filter if `prefer_grayscale` is true
242
18
fn apply_icon_style_filters(
243
18
    props_vec: &mut Vec<CssPropertyWithConditions>,
244
18
    system_style: &SystemStyle,
245
18
) {
246
18
    let icon_style = &system_style.icon_style;
247
    
248
    // Collect filters to apply
249
18
    let mut filters = Vec::new();
250
    
251
    // Grayscale filter: Uses a color matrix that converts to grayscale
252
    // Standard luminance weights: R*0.2126 + G*0.7152 + B*0.0722
253
18
    if icon_style.prefer_grayscale {
254
4
        // Grayscale color matrix (4x5):
255
4
        // [0.2126, 0.7152, 0.0722, 0, 0]  <- R output
256
4
        // [0.2126, 0.7152, 0.0722, 0, 0]  <- G output
257
4
        // [0.2126, 0.7152, 0.0722, 0, 0]  <- B output
258
4
        // [0,      0,      0,      1, 0]  <- A output
259
4
        let grayscale_matrix = StyleColorMatrix {
260
4
            m0: FloatValue::new(0.2126),
261
4
            m1: FloatValue::new(0.7152),
262
4
            m2: FloatValue::new(0.0722),
263
4
            m3: FloatValue::new(0.0),
264
4
            m4: FloatValue::new(0.0),
265
4
            m5: FloatValue::new(0.2126),
266
4
            m6: FloatValue::new(0.7152),
267
4
            m7: FloatValue::new(0.0722),
268
4
            m8: FloatValue::new(0.0),
269
4
            m9: FloatValue::new(0.0),
270
4
            m10: FloatValue::new(0.2126),
271
4
            m11: FloatValue::new(0.7152),
272
4
            m12: FloatValue::new(0.0722),
273
4
            m13: FloatValue::new(0.0),
274
4
            m14: FloatValue::new(0.0),
275
4
            m15: FloatValue::new(0.0),
276
4
            m16: FloatValue::new(0.0),
277
4
            m17: FloatValue::new(0.0),
278
4
            m18: FloatValue::new(1.0),
279
4
            m19: FloatValue::new(0.0),
280
4
        };
281
4
        filters.push(StyleFilter::ColorMatrix(grayscale_matrix));
282
14
    }
283
    
284
    // Apply tint color as a flood filter if specified
285
18
    if let azul_css::props::basic::color::OptionColorU::Some(tint) = &icon_style.tint_color {
286
2
        filters.push(StyleFilter::Flood(*tint));
287
16
    }
288
    
289
    // Add filters if any were collected
290
18
    if !filters.is_empty() {
291
5
        props_vec.push(CssPropertyWithConditions::simple(
292
5
            CssProperty::Filter(CssPropertyValue::Exact(StyleFilterVec::from_vec(filters)))
293
5
        ));
294
13
    }
295
18
}
296

            
297
/// Apply SystemStyle-aware color modifications for font icons.
298
///
299
/// Font icons can use text color directly, so we can:
300
/// - Apply tint color as text color
301
/// - Inherit text color from parent
302
26
fn apply_font_icon_color(
303
26
    props_vec: &mut Vec<CssPropertyWithConditions>,
304
26
    system_style: &SystemStyle,
305
26
) {
306
26
    let icon_style = &system_style.icon_style;
307
    
308
    // If tint color is specified, use it as the text color
309
26
    if let azul_css::props::basic::color::OptionColorU::Some(tint) = &icon_style.tint_color {
310
2
        props_vec.push(CssPropertyWithConditions::simple(
311
2
            CssProperty::TextColor(CssPropertyValue::Exact(StyleTextColor { inner: *tint }))
312
2
        ));
313
24
    }
314
    // Note: inherit_text_color doesn't need explicit handling - text color
315
    // is inherited by default in CSS. We only need to NOT override it.
316
26
}
317

            
318
// IconProviderHandle Helper Functions
319

            
320
/// Register an image icon in a pack
321
6
pub fn register_image_icon(provider: &mut IconProviderHandle, pack_name: &str, icon_name: &str, image: ImageRef) {
322
    // Get dimensions from ImageRef
323
6
    let size = image.get_size();
324
6
    let data = ImageIconData { 
325
6
        image, 
326
6
        width: size.width, 
327
6
        height: size.height,
328
6
    };
329
6
    provider.register_icon(pack_name, icon_name, RefAny::new(data));
330
6
}
331

            
332
/// Register icons from a ZIP file (file names become icon names)
333
#[cfg(feature = "zip")]
334
4
pub fn register_icons_from_zip(provider: &mut IconProviderHandle, pack_name: &str, zip_bytes: &[u8]) {
335
4
    for (icon_name, image, width, height) in load_images_from_zip(zip_bytes) {
336
        let data = ImageIconData { image, width, height };
337
        provider.register_icon(pack_name, &icon_name, RefAny::new(data));
338
    }
339
4
}
340

            
341
#[cfg(not(feature = "zip"))]
342
pub fn register_icons_from_zip(_provider: &mut IconProviderHandle, _pack_name: &str, _zip_bytes: &[u8]) {
343
    // ZIP support not enabled — the caller explicitly handed us an icon pack
344
    // and NOTHING got registered; every later lookup will just miss. Say so
345
    // once (this gate is hit by DEFAULT builds: `zip` is not a default
346
    // feature of azul-layout).
347
    static ANNOUNCE: std::sync::Once = std::sync::Once::new();
348
    ANNOUNCE.call_once(|| {
349
        eprintln!(
350
            "[azul][icons] register_icons_from_zip called, but this build has no \
351
             `zip` feature — NO icons were registered from the pack. Rebuild \
352
             azul-layout with the `zip` (+ `image_decoding`) features"
353
        );
354
    });
355
}
356

            
357
/// Register a font icon in a pack
358
12
pub fn register_font_icon(provider: &mut IconProviderHandle, pack_name: &str, icon_name: &str, font: FontRef, icon_char: &str) {
359
12
    let data = FontIconData { 
360
12
        font, 
361
12
        icon_char: icon_char.to_string() 
362
12
    };
363
12
    provider.register_icon(pack_name, icon_name, RefAny::new(data));
364
12
}
365

            
366
// ============================================================================
367
// ZIP Support
368
// ============================================================================
369

            
370
/// Load all images from a ZIP file, returning (`icon_name`, `ImageRef`, width, height)
371
#[cfg(all(feature = "zip", feature = "image_decoding"))]
372
#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/counter/fixed-point cast
373
9
fn load_images_from_zip(zip_bytes: &[u8]) -> Vec<(String, ImageRef, f32, f32)> {
374
    use crate::zip::{ZipFile, ZipReadConfig};
375
    use crate::image::decode::{decode_raw_image_from_any_bytes, ResultRawImageDecodeImageError};
376
    use std::path::Path;
377
    
378
9
    let mut result = Vec::new();
379
9
    let config = ZipReadConfig::default();
380
9
    let Ok(entries) = ZipFile::list(zip_bytes, &config) else {
381
9
        return result;
382
    };
383
    
384
    for entry in &entries {
385
        if entry.path.ends_with('/') { continue; } // Skip directories
386
        
387
        let Ok(Some(file_bytes)) = ZipFile::get_single_file(zip_bytes, entry, &config) else {
388
            continue;
389
        };
390
        
391
        // Decode as image
392
        if let ResultRawImageDecodeImageError::Ok(raw_image) = decode_raw_image_from_any_bytes(&file_bytes) {
393
            // Icon name = filename without extension
394
            let path = Path::new(&entry.path);
395
            let icon_name = path.file_stem()
396
                .and_then(|s| s.to_str())
397
                .unwrap_or("")
398
                .to_string();
399
            
400
            let width = raw_image.width as f32;
401
            let height = raw_image.height as f32;
402
            
403
            if let Some(image) = ImageRef::new_rawimage(raw_image) {
404
                result.push((icon_name, image, width, height));
405
            }
406
        }
407
    }
408
    
409
    result
410
9
}
411

            
412
#[cfg(not(all(feature = "zip", feature = "image_decoding")))]
413
fn load_images_from_zip(_zip_bytes: &[u8]) -> Vec<(String, ImageRef, f32, f32)> {
414
    // Only reachable when `zip` is on but `image_decoding` is off (the
415
    // zip-off case never calls this) — same silent-empty trap, so say so.
416
    static ANNOUNCE: std::sync::Once = std::sync::Once::new();
417
    ANNOUNCE.call_once(|| {
418
        eprintln!(
419
            "[azul][icons] icon ZIP was readable but this build has no \
420
             `image_decoding` feature — 0 images decoded, NO icons registered"
421
        );
422
    });
423
    Vec::new()
424
}
425

            
426
// ============================================================================
427
// Material Icons Registration
428
// ============================================================================
429

            
430
/// Register all Material Icons in the provider.
431
/// 
432
/// This registers all 2234 Material Icons from the `material-icons` crate.
433
/// Each icon is registered under the "material-icons" pack with its HTML name
434
/// (e.g., "home", "settings", "`arrow_back`", etc.).
435
/// 
436
/// Requires the "icons" feature with material-icons crate.
437
#[cfg(feature = "icons")]
438
1
pub fn register_material_icons(provider: &mut IconProviderHandle, font: &FontRef) {
439
    use material_icons::{ALL_ICONS, icon_to_char, icon_to_html_name};
440
    
441
    // Register all Material Icons with their Unicode codepoints
442
2235
    for icon in &ALL_ICONS {
443
2234
        let icon_char = icon_to_char(*icon);
444
2234
        let name = icon_to_html_name(icon);
445
2234
        
446
2234
        let data = FontIconData {
447
2234
            font: font.clone(),
448
2234
            icon_char: icon_char.to_string(),
449
2234
        };
450
2234
        provider.register_icon("material-icons", name, RefAny::new(data));
451
2234
    }
452
1
}
453

            
454
#[cfg(not(feature = "icons"))]
455
pub fn register_material_icons(_provider: &mut IconProviderHandle, _font: FontRef) {
456
    // Icons feature not enabled — the caller asked for 2234 Material Icons
457
    // and got zero, silently. Say so once.
458
    static ANNOUNCE: std::sync::Once = std::sync::Once::new();
459
    ANNOUNCE.call_once(|| {
460
        eprintln!(
461
            "[azul][icons] register_material_icons called, but this build has no \
462
             `icons` feature — NO Material Icons were registered"
463
        );
464
    });
465
}
466

            
467
/// Load the embedded Material Icons font and register all standard icons.
468
/// 
469
/// This uses the `material-icons` crate which embeds the Material Icons TTF font.
470
/// The font is Apache 2.0 licensed by Google.
471
/// 
472
/// Returns true if registration was successful.
473
/// Register all Material Icons from caller-supplied TTF bytes.
474
///
475
/// The font bytes are NOT embedded here. `azul-doc codegen all` generates
476
/// `target/codegen/material_icons.ttf.br`, and `azul-doc` builds (depends
477
/// on) `azul-layout` — so `include!`ing that generated artifact in this
478
/// crate is a build cycle (it bit us on `cargo clean`). The `include!` +
479
/// brotli-decompression live in `azul-dll` (downstream of codegen), which
480
/// passes the decompressed TTF in here.
481
#[cfg(all(feature = "icons", feature = "text_layout"))]
482
4
pub fn register_embedded_material_icons(
483
4
    provider: &mut IconProviderHandle,
484
4
    font_bytes: &[u8],
485
4
) -> bool {
486
    use crate::font::parsed::ParsedFont;
487
    use crate::parsed_font_to_font_ref;
488

            
489
4
    let mut warnings = Vec::new();
490
4
    let Some(parsed_font) = ParsedFont::from_bytes(font_bytes, 0, &mut warnings) else {
491
4
        return false;
492
    };
493

            
494
    let font_ref = parsed_font_to_font_ref(parsed_font);
495
    register_material_icons(provider, &font_ref);
496

            
497
    true
498
4
}
499

            
500
#[cfg(not(all(feature = "icons", feature = "text_layout")))]
501
pub fn register_embedded_material_icons(
502
    _provider: &mut IconProviderHandle,
503
    _font_bytes: &[u8],
504
) -> bool {
505
    // Icons or text_layout feature not enabled. Returning false is a weak
506
    // signal callers routinely ignore — name the gate once.
507
    static ANNOUNCE: std::sync::Once = std::sync::Once::new();
508
    ANNOUNCE.call_once(|| {
509
        eprintln!(
510
            "[azul][icons] register_embedded_material_icons called, but this build \
511
             lacks the `icons` and/or `text_layout` feature — NO icons registered \
512
             (returning false)"
513
        );
514
    });
515
    false
516
}
517

            
518
// ============================================================================
519
// Convenience Functions
520
// ============================================================================
521

            
522
/// Create an `IconProviderHandle` with the default resolver.
523
17
pub fn create_default_icon_provider() -> IconProviderHandle {
524
17
    IconProviderHandle::with_resolver(default_icon_resolver)
525
17
}
526

            
527
// The embedded Material Icons font bytes (the `include!` of the
528
// codegen-generated `target/codegen/material_icons.ttf.br` + brotli
529
// decompression) deliberately live in `azul-dll`, not here — see
530
// `register_embedded_material_icons` above for why (build-cycle: azul-doc
531
// builds azul-layout to generate that artifact).
532

            
533
// ============================================================================
534
// Tests
535
// ============================================================================
536

            
537
#[cfg(test)]
538
mod tests {
539
    use super::*;
540

            
541
    #[test]
542
1
    fn test_default_resolver_no_data() {
543
1
        let style = SystemStyle::default();
544
1
        let original = StyledDom::default();
545
        
546
1
        let result = default_icon_resolver(OptionRefAny::None, &original, &style);
547
        
548
        // Without data, should return empty div StyledDom
549
1
        assert_eq!(result.node_data.as_ref().len(), 1);
550
1
    }
551
    
552
    #[test]
553
1
    fn test_create_default_provider() {
554
1
        let provider = create_default_icon_provider();
555
1
        assert!(provider.list_packs().is_empty());
556
1
    }
557
}
558

            
559
#[cfg(test)]
560
#[allow(
561
    clippy::float_cmp,
562
    clippy::items_after_statements,
563
    clippy::redundant_clone,
564
    clippy::cast_possible_truncation,
565
    clippy::cast_precision_loss,
566
    clippy::cast_sign_loss,
567
    clippy::cast_lossless,
568
    clippy::unreadable_literal,
569
    clippy::too_many_lines,
570
    clippy::many_single_char_names,
571
    clippy::similar_names,
572
    unused_qualifications,
573
    unreachable_pub,
574
    private_interfaces
575
)] // pedantic lints are noise in adversarial test code
576
mod autotest_generated {
577
    use azul_core::{
578
        a11y::SmallAriaInfo,
579
        dom::NodeType,
580
        resources::RawImageFormat,
581
    };
582
    use azul_css::props::basic::color::{ColorU, OptionColorU};
583

            
584
    use super::*;
585

            
586
    // ---------------------------------------------------------------------
587
    // helpers
588
    // ---------------------------------------------------------------------
589

            
590
    /// A `FontRef` whose `parsed` pointer addresses a `'static` byte and whose
591
    /// destructor is a no-op, so nothing is freed on drop. Sound here because
592
    /// nothing on the icon-resolution path ever dereferences `parsed` (only
593
    /// `cpurender::raster` does, and that is not reached from `StyledDom::create`).
594
    fn dummy_font_ref() -> FontRef {
595
        static DUMMY_FONT_DATA: u8 = 0;
596
        extern "C" fn dummy_destructor(_: *mut core::ffi::c_void) {}
597
        FontRef::new(
598
            core::ptr::addr_of!(DUMMY_FONT_DATA).cast::<core::ffi::c_void>(),
599
            dummy_destructor,
600
        )
601
    }
602

            
603
    /// A null (non-decoded) `ImageRef` of the given pixel size — `get_size()`
604
    /// reports exactly `width` / `height`, with no allocation.
605
    fn null_img(width: usize, height: usize) -> ImageRef {
606
        ImageRef::null_image(width, height, RawImageFormat::RGBA8, Vec::new())
607
    }
608

            
609
    /// `ImageIconData` with explicitly-chosen (possibly hostile) f32 dimensions.
610
    fn image_icon(width: f32, height: f32) -> ImageIconData {
611
        ImageIconData {
612
            image: null_img(1, 1),
613
            width,
614
            height,
615
        }
616
    }
617

            
618
    fn font_icon(icon_char: &str) -> FontIconData {
619
        FontIconData {
620
            font: dummy_font_ref(),
621
            icon_char: icon_char.to_string(),
622
        }
623
    }
624

            
625
    fn grayscale_style() -> SystemStyle {
626
        let mut s = SystemStyle::default();
627
        s.icon_style.prefer_grayscale = true;
628
        s
629
    }
630

            
631
    fn tint_style(color: ColorU) -> SystemStyle {
632
        let mut s = SystemStyle::default();
633
        s.icon_style.tint_color = OptionColorU::Some(color);
634
        s
635
    }
636

            
637
    /// A "normal" original icon DOM: a single div carrying `props` as inline style.
638
    fn original_with(props: Vec<CssPropertyWithConditions>) -> StyledDom {
639
        let mut dom = Dom::create_div();
640
        dom.root
641
            .set_css_props(CssPropertyWithConditionsVec::from_vec(props));
642
        StyledDom::create(&mut dom, Css::empty())
643
    }
644

            
645
    /// A degenerate `StyledDom` with **zero** nodes — drives the `else` branch of
646
    /// `create_{image,font}_icon_from_original`, which `StyledDom::default()` never
647
    /// reaches (it always has a body node).
648
    fn original_without_nodes() -> StyledDom {
649
        StyledDom {
650
            node_data: Vec::new().into(),
651
            ..StyledDom::default()
652
        }
653
    }
654

            
655
    /// Every inline property on every node of the result, in document order.
656
    /// (Collected across all nodes rather than `node_data[0]` so the assertions
657
    /// survive any future anonymous-node insertion in `StyledDom::create`.)
658
    fn all_props(dom: &StyledDom) -> Vec<CssPropertyWithConditions> {
659
        dom.node_data
660
            .as_ref()
661
            .iter()
662
            .flat_map(|nd| {
663
                nd.get_style()
664
                    .iter_inline_properties()
665
                    .map(|(property, apply_if)| CssPropertyWithConditions {
666
                        property: property.clone(),
667
                        apply_if: apply_if.clone(),
668
                    })
669
                    .collect::<Vec<_>>()
670
            })
671
            .collect()
672
    }
673

            
674
    fn width_px(dom: &StyledDom) -> Option<f32> {
675
        all_props(dom).into_iter().find_map(|p| match p.property {
676
            CssProperty::Width(CssPropertyValue::Exact(LayoutWidth::Px(px))) => Some(px.number.get()),
677
            _ => None,
678
        })
679
    }
680

            
681
    fn height_px(dom: &StyledDom) -> Option<f32> {
682
        all_props(dom).into_iter().find_map(|p| match p.property {
683
            CssProperty::Height(CssPropertyValue::Exact(LayoutHeight::Px(px))) => {
684
                Some(px.number.get())
685
            }
686
            _ => None,
687
        })
688
    }
689

            
690
    fn count_widths(dom: &StyledDom) -> usize {
691
        all_props(dom)
692
            .iter()
693
            .filter(|p| matches!(p.property, CssProperty::Width(_)))
694
            .count()
695
    }
696

            
697
    fn text_of(dom: &StyledDom) -> Option<String> {
698
        dom.node_data
699
            .as_ref()
700
            .iter()
701
            .find_map(|nd| match nd.get_node_type() {
702
                NodeType::Text(t) => Some(t.as_str().to_string()),
703
                _ => None,
704
            })
705
    }
706

            
707
    fn has_image_node(dom: &StyledDom) -> bool {
708
        dom.node_data
709
            .as_ref()
710
            .iter()
711
            .any(|nd| matches!(nd.get_node_type(), NodeType::Image(_)))
712
    }
713

            
714
    /// All `StyleFilter`s across every `filter:` property in the list.
715
    fn filters_of(props: &[CssPropertyWithConditions]) -> Vec<StyleFilter> {
716
        props
717
            .iter()
718
            .filter_map(|p| match &p.property {
719
                CssProperty::Filter(CssPropertyValue::Exact(v)) => Some(v.as_ref().to_vec()),
720
                _ => None,
721
            })
722
            .flatten()
723
            .collect()
724
    }
725

            
726
    fn text_color_of(props: &[CssPropertyWithConditions]) -> Option<ColorU> {
727
        props.iter().find_map(|p| match &p.property {
728
            CssProperty::TextColor(CssPropertyValue::Exact(c)) => Some(c.inner),
729
            _ => None,
730
        })
731
    }
732

            
733
    fn resolve(data: RefAny, original: &StyledDom, style: &SystemStyle) -> StyledDom {
734
        default_icon_resolver(OptionRefAny::Some(data), original, style)
735
    }
736

            
737
    // ---------------------------------------------------------------------
738
    // default_icon_resolver — dispatch
739
    // ---------------------------------------------------------------------
740

            
741
    #[test]
742
    fn resolver_none_yields_single_unstyled_div() {
743
        let out = default_icon_resolver(
744
            OptionRefAny::None,
745
            &StyledDom::default(),
746
            &SystemStyle::default(),
747
        );
748
        assert_eq!(out.node_data.as_ref().len(), 1);
749
        assert!(matches!(
750
            out.node_data.as_ref()[0].get_node_type(),
751
            NodeType::Div
752
        ));
753
        // The "not found" placeholder must carry no styling at all — in particular
754
        // it must not inherit the original's width/height.
755
        assert!(all_props(&out).is_empty());
756
    }
757

            
758
    #[test]
759
    fn resolver_unknown_refany_type_yields_empty_div() {
760
        // A RefAny holding neither ImageIconData nor FontIconData must fall through
761
        // to the placeholder rather than panicking on a bad downcast.
762
        struct NotAnIconAtAll {
763
            _payload: [u64; 4],
764
        }
765
        let data = RefAny::new(NotAnIconAtAll { _payload: [7; 4] });
766
        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
767

            
768
        assert_eq!(out.node_data.as_ref().len(), 1);
769
        assert!(matches!(
770
            out.node_data.as_ref()[0].get_node_type(),
771
            NodeType::Div
772
        ));
773
        assert!(!has_image_node(&out));
774
        assert!(text_of(&out).is_none());
775
    }
776

            
777
    #[test]
778
    fn resolver_image_icon_yields_image_node_with_default_dimensions() {
779
        let out = resolve(
780
            RefAny::new(image_icon(32.0, 24.0)),
781
            &StyledDom::default(),
782
            &SystemStyle::default(),
783
        );
784
        assert!(has_image_node(&out));
785
        assert_eq!(width_px(&out), Some(32.0));
786
        assert_eq!(height_px(&out), Some(24.0));
787
    }
788

            
789
    #[test]
790
    fn resolver_font_icon_yields_text_node_with_font_family() {
791
        let font = dummy_font_ref();
792
        let data = RefAny::new(FontIconData {
793
            font: font.clone(),
794
            icon_char: "\u{e88a}".to_string(),
795
        });
796
        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
797

            
798
        assert_eq!(out.node_data.as_ref().len(), 1);
799
        assert_eq!(text_of(&out).as_deref(), Some("\u{e88a}"));
800

            
801
        // The registered font must be the one that ends up in `font-family`.
802
        let has_font = all_props(&out).iter().any(|p| match &p.property {
803
            CssProperty::FontFamily(CssPropertyValue::Exact(families)) => families
804
                .as_ref()
805
                .iter()
806
                .any(|f| matches!(f, StyleFontFamily::Ref(fr) if *fr == font)),
807
            _ => false,
808
        });
809
        assert!(has_font, "font-family with the icon's FontRef must be set");
810
    }
811

            
812
    // ---------------------------------------------------------------------
813
    // default_icon_resolver — degenerate originals
814
    // ---------------------------------------------------------------------
815

            
816
    #[test]
817
    fn image_icon_with_node_less_original_still_gets_dimensions() {
818
        // `original.node_data.first()` is None -> the fallback branch must still
819
        // produce a fully-sized image instead of panicking / emitting no style.
820
        let original = original_without_nodes();
821
        let out = resolve(
822
            RefAny::new(image_icon(16.0, 16.0)),
823
            &original,
824
            &SystemStyle::default(),
825
        );
826
        assert!(has_image_node(&out));
827
        assert_eq!(width_px(&out), Some(16.0));
828
        assert_eq!(height_px(&out), Some(16.0));
829
    }
830

            
831
    #[test]
832
    fn font_icon_with_node_less_original_still_gets_font() {
833
        let original = original_without_nodes();
834
        let out = resolve(RefAny::new(font_icon("A")), &original, &SystemStyle::default());
835
        assert_eq!(text_of(&out).as_deref(), Some("A"));
836
        assert!(all_props(&out)
837
            .iter()
838
            .any(|p| matches!(p.property, CssProperty::FontFamily(_))));
839
    }
840

            
841
    // ---------------------------------------------------------------------
842
    // numeric limits: the icon dimensions are attacker-controlled f32s
843
    // ---------------------------------------------------------------------
844

            
845
    #[test]
846
    fn image_icon_nan_dimensions_saturate_to_zero_without_panicking() {
847
        // FloatValue stores `(v * 1000.0) as isize`; `NaN as isize` saturates to 0,
848
        // so a NaN-sized icon degrades to a 0x0 box rather than poisoning layout.
849
        let out = resolve(
850
            RefAny::new(image_icon(f32::NAN, f32::NAN)),
851
            &StyledDom::default(),
852
            &SystemStyle::default(),
853
        );
854
        let (w, h) = (
855
            width_px(&out).expect("width emitted"),
856
            height_px(&out).expect("height emitted"),
857
        );
858
        assert!(w.is_finite() && h.is_finite(), "NaN must not survive into CSS");
859
        assert_eq!(w, 0.0);
860
        assert_eq!(h, 0.0);
861
    }
862

            
863
    #[test]
864
    fn image_icon_infinite_dimensions_saturate_to_finite_values() {
865
        let out = resolve(
866
            RefAny::new(image_icon(f32::INFINITY, f32::NEG_INFINITY)),
867
            &StyledDom::default(),
868
            &SystemStyle::default(),
869
        );
870
        let w = width_px(&out).expect("width emitted");
871
        let h = height_px(&out).expect("height emitted");
872
        assert!(w.is_finite(), "+inf must saturate, got {w}");
873
        assert!(h.is_finite(), "-inf must saturate, got {h}");
874
        assert!(w > 0.0 && h < 0.0, "saturation must keep the sign");
875
    }
876

            
877
    #[test]
878
    fn image_icon_negative_dimensions_are_passed_through_unclamped() {
879
        // Documents current behaviour: the resolver does NOT reject negative sizes,
880
        // it forwards them verbatim into `width` / `height`.
881
        let out = resolve(
882
            RefAny::new(image_icon(-32.0, -1.5)),
883
            &StyledDom::default(),
884
            &SystemStyle::default(),
885
        );
886
        assert_eq!(width_px(&out), Some(-32.0));
887
        assert_eq!(height_px(&out), Some(-1.5));
888
    }
889

            
890
    #[test]
891
    fn register_image_icon_with_usize_max_size_saturates() {
892
        // `ImageRef::get_size()` casts usize -> f32 (1.8e19); FloatValue then scales
893
        // by 1000 and casts to isize, which must saturate rather than wrap/panic.
894
        let mut provider = create_default_icon_provider();
895
        register_image_icon(
896
            &mut provider,
897
            "huge",
898
            "big",
899
            null_img(usize::MAX, usize::MAX),
900
        );
901
        let data = provider.lookup("big").expect("icon registered");
902
        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
903

            
904
        let w = width_px(&out).expect("width emitted");
905
        assert!(w.is_finite() && w > 0.0, "usize::MAX size must saturate finitely, got {w}");
906
    }
907

            
908
    #[test]
909
    fn image_icon_zero_size_is_preserved() {
910
        let out = resolve(
911
            RefAny::new(image_icon(0.0, 0.0)),
912
            &StyledDom::default(),
913
            &SystemStyle::default(),
914
        );
915
        assert_eq!(width_px(&out), Some(0.0));
916
        assert_eq!(height_px(&out), Some(0.0));
917
    }
918

            
919
    // ---------------------------------------------------------------------
920
    // style copying / precedence
921
    // ---------------------------------------------------------------------
922

            
923
    #[test]
924
    fn original_dimensions_win_over_image_defaults() {
925
        let original = original_with(vec![
926
            CssPropertyWithConditions::simple(CssProperty::width(LayoutWidth::px(999.0))),
927
            CssPropertyWithConditions::simple(CssProperty::height(LayoutHeight::px(888.0))),
928
        ]);
929
        let out = resolve(
930
            RefAny::new(image_icon(32.0, 32.0)),
931
            &original,
932
            &SystemStyle::default(),
933
        );
934

            
935
        assert_eq!(width_px(&out), Some(999.0));
936
        assert_eq!(height_px(&out), Some(888.0));
937
        // ...and the 32px default must not be appended as a *second* width.
938
        assert_eq!(count_widths(&out), 1);
939
    }
940

            
941
    #[test]
942
    fn copy_appropriate_styles_vec_round_trips_exactly() {
943
        // encode (set_css_props -> Css) == decode (copy_appropriate_styles_vec)
944
        let props = vec![
945
            CssPropertyWithConditions::simple(CssProperty::width(LayoutWidth::px(12.5))),
946
            CssPropertyWithConditions::simple(CssProperty::height(LayoutHeight::px(7.0))),
947
        ];
948
        let mut nd = NodeData::create_div();
949
        nd.set_css_props(CssPropertyWithConditionsVec::from_vec(props.clone()));
950

            
951
        assert_eq!(copy_appropriate_styles_vec(&nd), props);
952
    }
953

            
954
    #[test]
955
    fn copy_appropriate_styles_vec_of_unstyled_node_is_empty() {
956
        let nd = NodeData::create_div();
957
        assert!(copy_appropriate_styles_vec(&nd).is_empty());
958
    }
959

            
960
    #[test]
961
    fn copy_appropriate_styles_vec_preserves_order_of_many_props() {
962
        // 512 same-typed declarations: nothing may be deduplicated or reordered,
963
        // otherwise the last-wins cascade of the copied icon style would flip.
964
        let props: Vec<CssPropertyWithConditions> = (0..512u32)
965
            .map(|i| {
966
                CssPropertyWithConditions::simple(CssProperty::width(LayoutWidth::px(i as f32)))
967
            })
968
            .collect();
969
        let mut nd = NodeData::create_div();
970
        nd.set_css_props(CssPropertyWithConditionsVec::from_vec(props.clone()));
971

            
972
        let copied = copy_appropriate_styles_vec(&nd);
973
        assert_eq!(copied.len(), 512);
974
        assert_eq!(copied, props);
975
    }
976

            
977
    #[test]
978
    fn accessibility_info_is_copied_onto_the_resolved_icon() {
979
        let mut dom = Dom::create_div().with_accessibility_info(SmallAriaInfo::label("Save").to_full_info());
980
        let original = StyledDom::create(&mut dom, Css::empty());
981

            
982
        let out = resolve(
983
            RefAny::new(image_icon(8.0, 8.0)),
984
            &original,
985
            &SystemStyle::default(),
986
        );
987

            
988
        let a11y = out
989
            .node_data
990
            .as_ref()
991
            .iter()
992
            .find_map(NodeData::get_accessibility_info)
993
            .expect("a11y info must survive icon resolution");
994
        assert_eq!(
995
            a11y.accessibility_name.as_ref().map(|s| s.as_str()),
996
            Some("Save")
997
        );
998
    }
999

            
    // ---------------------------------------------------------------------
    // apply_icon_style_filters
    // ---------------------------------------------------------------------
    #[test]
    fn icon_filters_default_style_adds_nothing() {
        let mut props = Vec::new();
        apply_icon_style_filters(&mut props, &SystemStyle::default());
        assert!(props.is_empty(), "default SystemStyle must not synthesise a filter");
    }
    #[test]
    fn icon_filters_grayscale_uses_quantised_luminance_matrix() {
        let mut props = Vec::new();
        apply_icon_style_filters(&mut props, &grayscale_style());
        let filters = filters_of(&props);
        assert_eq!(filters.len(), 1);
        let StyleFilter::ColorMatrix(m) = &filters[0] else {
            panic!("prefer_grayscale must emit a ColorMatrix filter, got {:?}", filters[0]);
        };
        // Rec.709 luminance weights, rounded through FloatValue's 1/1000 fixed point.
        for r in [m.m0, m.m5, m.m10] {
            assert!((r.get() - 0.2126).abs() < 0.001, "R weight {}", r.get());
        }
        for g in [m.m1, m.m6, m.m11] {
            assert!((g.get() - 0.7152).abs() < 0.001, "G weight {}", g.get());
        }
        for b in [m.m2, m.m7, m.m12] {
            assert!((b.get() - 0.0722).abs() < 0.001, "B weight {}", b.get());
        }
        // Alpha row must be pass-through, or grayscale icons would turn opaque/invisible.
        assert_eq!(m.m18.get(), 1.0);
        assert_eq!(m.m15.get(), 0.0);
        assert_eq!(m.m19.get(), 0.0);
        // FloatValue truncates at 3 decimals: the 4th digit of 0.2126 is lost.
        assert_ne!(m.m0.get(), 0.2126);
    }
    #[test]
    fn icon_filters_tint_emits_flood_even_when_fully_transparent() {
        // a == 0 is still forwarded — the resolver does not treat it as "no tint".
        let transparent = ColorU { r: 1, g: 2, b: 3, a: 0 };
        let mut props = Vec::new();
        apply_icon_style_filters(&mut props, &tint_style(transparent));
        let filters = filters_of(&props);
        assert_eq!(filters.len(), 1);
        assert!(matches!(filters[0], StyleFilter::Flood(c) if c == transparent));
    }
    #[test]
    fn icon_filters_grayscale_and_tint_are_ordered_matrix_then_flood() {
        let tint = ColorU { r: 255, g: 0, b: 128, a: 255 };
        let mut style = grayscale_style();
        style.icon_style.tint_color = OptionColorU::Some(tint);
        let mut props = Vec::new();
        apply_icon_style_filters(&mut props, &style);
        // Both filters must live in ONE `filter:` declaration (a second declaration
        // would overwrite the first in the cascade, silently dropping the grayscale).
        let filter_decls = props
            .iter()
            .filter(|p| matches!(p.property, CssProperty::Filter(_)))
            .count();
        assert_eq!(filter_decls, 1);
        let filters = filters_of(&props);
        assert_eq!(filters.len(), 2);
        assert!(matches!(filters[0], StyleFilter::ColorMatrix(_)));
        assert!(matches!(filters[1], StyleFilter::Flood(c) if c == tint));
    }
    #[test]
    fn icon_filters_preserve_pre_existing_properties() {
        let mut props = vec![CssPropertyWithConditions::simple(CssProperty::width(
            LayoutWidth::px(4.0),
        ))];
        apply_icon_style_filters(&mut props, &grayscale_style());
        assert_eq!(props.len(), 2);
        assert!(matches!(props[0].property, CssProperty::Width(_)), "existing props must not be clobbered");
        assert!(matches!(props[1].property, CssProperty::Filter(_)));
    }
    #[test]
    fn image_icon_grayscale_reaches_the_resolved_dom() {
        let out = resolve(
            RefAny::new(image_icon(10.0, 10.0)),
            &StyledDom::default(),
            &grayscale_style(),
        );
        let filters = filters_of(&all_props(&out));
        assert_eq!(filters.len(), 1);
        assert!(matches!(filters[0], StyleFilter::ColorMatrix(_)));
    }
    // ---------------------------------------------------------------------
    // apply_font_icon_color
    // ---------------------------------------------------------------------
    #[test]
    fn font_icon_color_default_style_adds_nothing() {
        let mut props = Vec::new();
        apply_font_icon_color(&mut props, &SystemStyle::default());
        assert!(props.is_empty());
    }
    #[test]
    fn font_icon_color_inherit_text_color_alone_is_a_noop() {
        // Documented: inheritance is CSS's default, so `inherit_text_color` must
        // *not* synthesise a `color:` declaration (that would break inheritance).
        let mut style = SystemStyle::default();
        style.icon_style.inherit_text_color = true;
        let mut props = Vec::new();
        apply_font_icon_color(&mut props, &style);
        assert!(props.is_empty());
    }
    #[test]
    fn font_icon_color_tint_becomes_text_color() {
        let tint = ColorU { r: 9, g: 8, b: 7, a: 6 };
        let mut props = Vec::new();
        apply_font_icon_color(&mut props, &tint_style(tint));
        assert_eq!(props.len(), 1);
        assert_eq!(text_color_of(&props), Some(tint));
    }
    #[test]
    fn font_icon_color_tint_wins_over_inherit_text_color() {
        let tint = ColorU { r: 1, g: 1, b: 1, a: 255 };
        let mut style = tint_style(tint);
        style.icon_style.inherit_text_color = true;
        let mut props = Vec::new();
        apply_font_icon_color(&mut props, &style);
        assert_eq!(text_color_of(&props), Some(tint));
    }
    #[test]
    fn font_icons_never_get_a_grayscale_filter() {
        // Font icons take the color path, not the filter path — a ColorMatrix here
        // would double-apply on top of the (inherited) text color.
        let out = resolve(
            RefAny::new(font_icon("\u{e88a}")),
            &StyledDom::default(),
            &grayscale_style(),
        );
        assert!(filters_of(&all_props(&out)).is_empty());
    }
    // ---------------------------------------------------------------------
    // unicode / huge strings in the icon char
    // ---------------------------------------------------------------------
    #[test]
    fn font_icon_empty_char_yields_empty_text_node() {
        let out = resolve(
            RefAny::new(font_icon("")),
            &StyledDom::default(),
            &SystemStyle::default(),
        );
        assert_eq!(text_of(&out).as_deref(), Some(""));
    }
    #[test]
    fn font_icon_hostile_unicode_round_trips_verbatim() {
        // ZWJ emoji sequence, RTL override, combining marks, an embedded NUL and a
        // lone PUA codepoint: none may be normalised, truncated or panicked on.
        for s in [
            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}",
            "\u{202E}gnippilf\u{202C}",
            "e\u{0301}\u{0327}\u{0328}",
            "a\0b",
            "\u{F8FF}",
            "\u{FFFD}",
        ] {
            let out = resolve(
                RefAny::new(font_icon(s)),
                &StyledDom::default(),
                &SystemStyle::default(),
            );
            assert_eq!(text_of(&out).as_deref(), Some(s), "icon_char {s:?} was altered");
        }
    }
    #[test]
    fn font_icon_huge_char_string_does_not_panic() {
        let huge = "\u{e88a}".repeat(65_536);
        let out = resolve(
            RefAny::new(font_icon(&huge)),
            &StyledDom::default(),
            &SystemStyle::default(),
        );
        assert_eq!(text_of(&out).map(|s| s.chars().count()), Some(65_536));
    }
    // ---------------------------------------------------------------------
    // registration helpers
    // ---------------------------------------------------------------------
    #[test]
    fn register_image_icon_lowercases_the_name_and_reads_size_from_the_imageref() {
        let mut provider = create_default_icon_provider();
        register_image_icon(&mut provider, "App-Images", "HOME", null_img(64, 32));
        // pack names are case-sensitive, icon names are normalised to lowercase
        assert_eq!(provider.list_packs(), vec![String::from("App-Images")]);
        assert_eq!(
            provider.list_icons_in_pack("App-Images"),
            vec![String::from("home")]
        );
        assert!(provider.list_icons_in_pack("app-images").is_empty());
        assert!(provider.has_icon("hOmE"));
        let data = provider.lookup("HOME").expect("case-insensitive lookup");
        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
        assert_eq!(width_px(&out), Some(64.0));
        assert_eq!(height_px(&out), Some(32.0));
    }
    #[test]
    fn register_font_icon_accepts_empty_pack_and_icon_names() {
        let mut provider = create_default_icon_provider();
        register_font_icon(&mut provider, "", "", dummy_font_ref(), "");
        assert_eq!(provider.list_packs(), vec![String::new()]);
        assert!(provider.has_icon(""));
        let data = provider.lookup("").expect("empty-named icon is still addressable");
        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
        assert_eq!(text_of(&out).as_deref(), Some(""));
    }
    #[test]
    fn register_icon_handles_oversized_and_unicode_names() {
        let mut provider = create_default_icon_provider();
        let long_name = "n".repeat(10_000);
        register_font_icon(&mut provider, "p", &long_name, dummy_font_ref(), "x");
        assert!(provider.has_icon(&long_name));
        // "İ" (U+0130) lowercases to TWO chars (i + U+0307); the key is the
        // lowercased form, so the dotless "i" must NOT match.
        register_font_icon(&mut provider, "p", "\u{130}", dummy_font_ref(), "y");
        let folded = "\u{130}".to_lowercase();
        assert!(provider.has_icon("\u{130}"));
        assert!(provider.has_icon(&folded));
        assert!(!provider.has_icon("i"));
    }
    #[test]
    fn duplicate_icon_across_packs_resolves_to_the_alphabetically_first_pack() {
        // "First match wins" iterates a BTreeMap => pack *name* order, NOT the
        // registration order. Registering into "zzz" first must not shadow "aaa".
        let mut provider = create_default_icon_provider();
        register_image_icon(&mut provider, "zzz", "dup", null_img(1, 1));
        register_image_icon(&mut provider, "aaa", "dup", null_img(2, 2));
        let data = provider.lookup("dup").expect("icon registered");
        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
        assert_eq!(
            width_px(&out),
            Some(2.0),
            "lookup must return the alphabetically-first pack's icon"
        );
    }
    #[test]
    fn re_registering_an_icon_replaces_it_and_unregistering_drops_the_empty_pack() {
        let mut provider = create_default_icon_provider();
        register_image_icon(&mut provider, "p", "icon", null_img(1, 1));
        register_image_icon(&mut provider, "p", "ICON", null_img(5, 5));
        assert_eq!(provider.list_icons_in_pack("p").len(), 1);
        let data = provider.lookup("icon").expect("icon registered");
        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
        assert_eq!(width_px(&out), Some(5.0));
        provider.unregister_icon("p", "IcOn");
        assert!(!provider.has_icon("icon"));
        assert!(provider.list_packs().is_empty(), "empty pack must be removed");
    }
    #[test]
    fn create_default_icon_provider_starts_empty_and_misses_resolve_to_a_placeholder() {
        let provider = create_default_icon_provider();
        assert!(provider.list_packs().is_empty());
        assert!(provider.lookup("nope").is_none());
        assert!(!provider.has_icon("nope"));
        let out = default_icon_resolver(
            OptionRefAny::from(provider.lookup("nope")),
            &StyledDom::default(),
            &SystemStyle::default(),
        );
        assert_eq!(out.node_data.as_ref().len(), 1);
        assert!(all_props(&out).is_empty());
    }
    // ---------------------------------------------------------------------
    // ZIP / font-bytes entry points (both cfg variants share these signatures)
    // ---------------------------------------------------------------------
    #[test]
    fn load_images_from_zip_rejects_malformed_archives() {
        assert!(load_images_from_zip(&[]).is_empty());
        assert!(load_images_from_zip(b"definitely not a zip file").is_empty());
        // valid local-file-header magic, truncated body
        assert!(load_images_from_zip(b"PK\x03\x04\x00\x00\x00\x00").is_empty());
        // End-of-central-directory magic claiming 0xFFFF entries that don't exist
        assert!(load_images_from_zip(b"PK\x05\x06\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF").is_empty());
        assert!(load_images_from_zip(&[0xFFu8; 4096]).is_empty());
    }
    #[test]
    fn register_icons_from_zip_registers_nothing_for_garbage_bytes() {
        for bytes in [
            &b""[..],
            &b"not a zip"[..],
            &b"PK\x03\x04\x00\x00\x00\x00"[..],
            &[0x00u8; 512][..],
        ] {
            let mut provider = create_default_icon_provider();
            register_icons_from_zip(&mut provider, "pack", bytes);
            assert!(
                provider.list_packs().is_empty(),
                "a malformed ZIP must not create a pack"
            );
        }
    }
    #[test]
    fn register_embedded_material_icons_rejects_non_font_bytes() {
        for bytes in [
            &b""[..],
            &b"this is not a TTF"[..],
            // sfnt version tag + nothing else
            &b"\x00\x01\x00\x00"[..],
            &[0xFFu8; 256][..],
        ] {
            let mut provider = create_default_icon_provider();
            let ok = register_embedded_material_icons(&mut provider, bytes);
            assert!(!ok, "corrupt font bytes must not report success");
            assert!(provider.list_packs().is_empty());
        }
    }
    #[cfg(feature = "icons")]
    #[test]
    fn register_material_icons_fills_a_single_lowercase_pack() {
        let mut provider = create_default_icon_provider();
        let font = dummy_font_ref();
        register_material_icons(&mut provider, &font);
        assert_eq!(provider.list_packs(), vec![String::from("material-icons")]);
        let names = provider.list_icons_in_pack("material-icons");
        assert!(names.len() > 1000, "expected the full icon set, got {}", names.len());
        assert!(
            names.iter().all(|n| *n == n.to_lowercase()),
            "every registered icon name must be normalised to lowercase"
        );
        assert!(provider.has_icon("home"));
        assert!(provider.has_icon("HOME"));
        let data = provider.lookup("home").expect("material 'home' icon");
        let out = resolve(data, &StyledDom::default(), &SystemStyle::default());
        assert!(text_of(&out).is_some(), "a material icon must resolve to a text node");
    }
}