1
//! GPU value caching for CSS transforms and opacity.
2
//!
3
//! This module manages the synchronization between DOM CSS properties (transforms and opacity)
4
//! and GPU-side keys used by WebRender. It tracks changes to transform and opacity values
5
//! and generates events when values are added, changed, or removed.
6
//!
7
//! # Performance
8
//!
9
//! The cache uses CPU feature detection (SSE/AVX on x86_64) to optimize transform calculations.
10
//! Values are only recalculated when CSS properties change, minimizing GPU updates.
11
//!
12
//! # Architecture
13
//!
14
//! - `GpuValueCache`: Stores current transform/opacity keys and values for all nodes
15
//! - `GpuEventChanges`: Contains delta events for transform/opacity changes
16
//! - `GpuTransformKeyEvent`: Events for transform additions, changes, and removals
17
//!
18
//! The cache is synchronized with the `StyledDom` on each frame, generating minimal
19
//! update events to send to the GPU.
20

            
21
use alloc::vec::Vec;
22
#[cfg(feature = "std")]
23
use std::collections::HashMap;
24
#[cfg(not(feature = "std"))]
25
use alloc::collections::BTreeMap as HashMap;
26
use core::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
27

            
28
use azul_css::props::style::StyleTransformOrigin;
29

            
30
use crate::{
31
    dom::{DomId, NodeId},
32
    resources::{OpacityKey, TransformKey},
33
    styled_dom::StyledDom,
34
    transform::{ComputedTransform3D, RotationMode, INITIALIZED, USE_AVX, USE_SSE},
35
};
36

            
37
/// Caches GPU transform and opacity keys and their current values for all nodes.
38
///
39
/// This cache stores the `WebRender` keys and computed values for nodes with
40
/// CSS transforms or opacity. It's synchronized with the `StyledDom` to detect
41
/// changes and generate minimal update events.
42
#[derive(Default, Debug, Clone)]
43
pub struct GpuValueCache {
44
    /// Vertical scrollbar thumb transform keys (keyed by scrollable node ID)
45
    pub transform_keys: HashMap<NodeId, TransformKey>,
46
    /// Current vertical scrollbar thumb transform values
47
    pub current_transform_values: HashMap<NodeId, ComputedTransform3D>,
48
    /// Horizontal scrollbar thumb transform keys (keyed by scrollable node ID)
49
    pub h_transform_keys: HashMap<NodeId, TransformKey>,
50
    /// Current horizontal scrollbar thumb transform values
51
    pub h_current_transform_values: HashMap<NodeId, ComputedTransform3D>,
52
    /// CSS transform keys (keyed by node ID) — for CSS `transform` property animation.
53
    /// Separate from scrollbar transform keys to avoid `SpatialTreeItemKey` collisions.
54
    pub css_transform_keys: HashMap<NodeId, TransformKey>,
55
    /// Current CSS transform values (keyed by node ID)
56
    pub css_current_transform_values: HashMap<NodeId, ComputedTransform3D>,
57
    /// ANIMATION transform keys (keyed by node ID).
58
    ///
59
    /// A separate channel from `css_transform_keys` on purpose. That map is
60
    /// OWNED by `synchronize`, which adds and removes entries to match the
61
    /// DOM's CSS `transform` property — so an animation writing into it has its
62
    /// keys evicted on the very next frame, and the element snaps instead of
63
    /// moving. Scrollbar thumbs already have their own channel for the same
64
    /// reason; this follows that precedent rather than fighting the cascade for
65
    /// one map.
66
    pub anim_transform_keys: HashMap<NodeId, TransformKey>,
67
    /// Current animation transform values (keyed by node ID).
68
    pub anim_current_transform_values: HashMap<NodeId, ComputedTransform3D>,
69
    /// Animation opacity keys (keyed by node ID).
70
    pub anim_opacity_keys: HashMap<NodeId, OpacityKey>,
71
    /// Current animation opacity values (keyed by node ID).
72
    pub anim_current_opacity_values: HashMap<NodeId, f32>,
73
    /// CSS opacity keys (keyed by node ID)
74
    pub opacity_keys: HashMap<NodeId, OpacityKey>,
75
    /// Current CSS opacity values (keyed by node ID)
76
    pub current_opacity_values: HashMap<NodeId, f32>,
77
    /// Vertical scrollbar opacity keys (keyed by DOM ID and scrollable node ID)
78
    pub scrollbar_v_opacity_keys: HashMap<(DomId, NodeId), OpacityKey>,
79
    /// Horizontal scrollbar opacity keys (keyed by DOM ID and scrollable node ID)
80
    pub scrollbar_h_opacity_keys: HashMap<(DomId, NodeId), OpacityKey>,
81
    /// Current vertical scrollbar opacity values
82
    pub scrollbar_v_opacity_values: HashMap<(DomId, NodeId), f32>,
83
    /// Current horizontal scrollbar opacity values
84
    pub scrollbar_h_opacity_values: HashMap<(DomId, NodeId), f32>,
85
}
86

            
87
/// Represents a change to a GPU transform key.
88
///
89
/// These events are generated when synchronizing the cache with the `StyledDom`
90
/// and are used to update `WebRender`'s transform state efficiently.
91
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
92
pub enum GpuTransformKeyEvent {
93
    /// A new transform was added to a node
94
    Added(NodeId, TransformKey, ComputedTransform3D),
95
    /// An existing transform was modified (includes old and new values)
96
    Changed(
97
        NodeId,
98
        TransformKey,
99
        ComputedTransform3D,
100
        ComputedTransform3D,
101
    ),
102
    /// A transform was removed from a node
103
    Removed(NodeId, TransformKey),
104
}
105

            
106
impl GpuValueCache {
107
    /// Creates an empty GPU value cache.
108
34
    #[must_use] pub fn empty() -> Self {
109
34
        Self::default()
110
34
    }
111

            
112
    /// Fingerprint of the KEY POPULATION the display-list builder consumes —
113
    /// which nodes carry which transform/opacity keys, and (for the channels
114
    /// the builder `zip`s with their value map) whether a value exists.
115
    ///
116
    /// This exists because the solver's structural-identity display-list cache
117
    /// keyed on (root subtree hash, viewport) alone, and the emitted list is
118
    /// ALSO a function of this population: `PushReferenceFrame` is emitted for
119
    /// a node exactly when it has a key+value pair. Diff-driven animation
120
    /// mints its keys AFTER the first layout (First/Last need solved rects),
121
    /// so the very next relayout of the unchanged DOM hit the cache and served
122
    /// the PRE-KEY display list back — no reference frames, so no GPU damage,
123
    /// so the animation was invisible and every subsequent screenshot froze.
124
    ///
125
    /// Deliberately a population fingerprint, not a value fingerprint: values
126
    /// change every animation tick, and serving the cached list across ticks
127
    /// is the entire point of routing animation through GPU keys. The hash
128
    /// covers exactly the maps the builder reads: css/anim transform keys
129
    /// (plus the keysets of their value maps — a key without a value emits
130
    /// nothing), scrollbar v/h thumb transform keys, and scrollbar v/h
131
    /// opacity keys. In-process comparison only, so hasher stability across
132
    /// runs is not required; iteration order is normalised by sorting.
133
    #[must_use]
134
9821
    pub fn dl_emission_fingerprint(&self) -> u64 {
135
        const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
136
        const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
137
9821
        let mut entries: Vec<(u8, u64, u64)> = Vec::with_capacity(
138
9821
            self.css_transform_keys.len()
139
9821
                + self.anim_transform_keys.len()
140
9821
                + self.css_current_transform_values.len()
141
9821
                + self.anim_current_transform_values.len()
142
9821
                + self.transform_keys.len()
143
9821
                + self.h_transform_keys.len()
144
9821
                + self.scrollbar_v_opacity_keys.len()
145
9821
                + self.scrollbar_h_opacity_keys.len(),
146
        );
147
9855
        for (n, k) in &self.css_transform_keys {
148
34
            entries.push((0, n.index() as u64, k.id as u64));
149
34
        }
150
9821
        for n in self.css_current_transform_values.keys() {
151
34
            entries.push((1, n.index() as u64, 0));
152
34
        }
153
9924
        for (n, k) in &self.anim_transform_keys {
154
103
            entries.push((2, n.index() as u64, k.id as u64));
155
103
        }
156
9821
        for n in self.anim_current_transform_values.keys() {
157
103
            entries.push((3, n.index() as u64, 0));
158
103
        }
159
10018
        for (n, k) in &self.transform_keys {
160
197
            entries.push((4, n.index() as u64, k.id as u64));
161
197
        }
162
9876
        for (n, k) in &self.h_transform_keys {
163
55
            entries.push((5, n.index() as u64, k.id as u64));
164
55
        }
165
10018
        for ((d, n), k) in &self.scrollbar_v_opacity_keys {
166
197
            entries.push((6, (d.inner as u64) << 32 | n.index() as u64, k.id as u64));
167
197
        }
168
9876
        for ((d, n), k) in &self.scrollbar_h_opacity_keys {
169
55
            entries.push((7, (d.inner as u64) << 32 | n.index() as u64, k.id as u64));
170
55
        }
171
        // Animated opacity binds `PushOpacity.opacity_key`, so its population
172
        // shapes the emitted list the same way animated transforms do.
173
9902
        for (n, k) in &self.anim_opacity_keys {
174
81
            entries.push((8, n.index() as u64, k.id as u64));
175
81
        }
176
9821
        for n in self.anim_current_opacity_values.keys() {
177
81
            entries.push((9, n.index() as u64, 0));
178
81
        }
179
9821
        entries.sort_unstable();
180
        // FNV-1a over the sorted entry words. Hand-rolled because this file
181
        // builds under no_std (where `HashMap` above is really `BTreeMap` and
182
        // `DefaultHasher` does not exist) — and in-process comparison needs
183
        // no cryptographic strength, only sensitivity to every entry.
184
9821
        let mut h: u64 = FNV_OFFSET;
185
10761
        for (tag, a, b) in entries {
186
2820
            for word in [u64::from(tag), a, b] {
187
2820
                h ^= word;
188
2820
                h = h.wrapping_mul(FNV_PRIME);
189
2820
            }
190
        }
191
        // An empty population must not collide with "no cache entry" sentinels
192
        // downstream; FNV_OFFSET is a fine non-zero value for it.
193
9821
        h
194
9821
    }
195

            
196
    /// Synchronizes the cache with the current `StyledDom`, generating change events
197
    /// for CSS transform and opacity additions, modifications, and removals.
198
    ///
199
    /// Split into read-only `compute_*_events` passes (which diff against the cache)
200
    /// and `apply_*_events` passes (which mutate it).
201
    #[must_use]
202
5733
    pub fn synchronize(&mut self, styled_dom: &StyledDom) -> GpuEventChanges {
203
5733
        Self::init_simd_features();
204

            
205
5733
        let transform_key_changes = self.compute_transform_events(styled_dom);
206
5733
        self.apply_transform_events(&transform_key_changes);
207

            
208
5733
        let opacity_key_changes = self.compute_opacity_events(styled_dom);
209
5733
        self.apply_opacity_events(&opacity_key_changes);
210

            
211
5733
        GpuEventChanges {
212
5733
            transform_key_changes,
213
5733
            opacity_key_changes,
214
5733
            scrollbar_opacity_changes: Vec::new(), // Filled by separate synchronization
215
5733
        }
216
5733
    }
217

            
218
    /// One-time CPU feature detection (SSE/AVX) for the transform math fast paths.
219
    #[allow(clippy::missing_const_for_fn)] // non-x86_64 body is empty; x86_64 uses atomics
220
5736
    fn init_simd_features() {
221
        #[cfg(target_arch = "x86_64")]
222
        unsafe {
223
5736
            if !INITIALIZED.load(AtomicOrdering::SeqCst) {
224
                use core::arch::x86_64::__cpuid;
225

            
226
36
                let mut cpuid = __cpuid(0);
227
36
                let n_ids = cpuid.eax;
228

            
229
36
                if n_ids > 0 {
230
36
                    // cpuid instruction is present
231
36
                    cpuid = __cpuid(1);
232
36
                    USE_SSE.store((cpuid.edx & (1_u32 << 25)) != 0, AtomicOrdering::SeqCst);
233
36
                    USE_AVX.store((cpuid.ecx & (1_u32 << 28)) != 0, AtomicOrdering::SeqCst);
234
36
                }
235
36
                INITIALIZED.store(true, AtomicOrdering::SeqCst);
236
5700
            }
237
        }
238
5736
    }
239

            
240
    /// Computes CSS-transform change events against the cached values (read-only).
241
5733
    fn compute_transform_events(&self, styled_dom: &StyledDom) -> Vec<GpuTransformKeyEvent> {
242
5733
        let css_property_cache = styled_dom.get_css_property_cache();
243
5733
        let node_data = styled_dom.node_data.as_container();
244
5733
        let node_states = styled_dom.styled_nodes.as_container();
245

            
246
5733
        let default_transform_origin = StyleTransformOrigin::default();
247

            
248
        // calculate the transform values of every single node that has a non-default transform.
249
        //
250
        // GPU fast path: `has_transform` is a single bit in the compact cache.
251
        // The overwhelmingly common case is "no transform set", which now reads one
252
        // byte and bails — no cascade walk. Only nodes that actually have a
253
        // transform pay the slow-walk cost (required to retrieve the parsed value).
254
5733
        let mut events = (0..styled_dom.node_data.len())
255
285674
            .filter_map(|node_id| {
256
285674
                let node_id = NodeId::new(node_id);
257
285674
                let styled_node_state = &node_states[node_id].styled_node_state;
258
                // Bit-check short-circuit: only proceed if the node might have a transform.
259
285674
                if styled_node_state.is_normal() {
260
285667
                    if let Some(ref cc) = css_property_cache.compact_cache {
261
                        // M12.7: short-circuit the empty-map get. hashbrown's
262
                        // empty-map probe touches the static empty control-group,
263
                        // which mis-lifts to wasm (out-of-bounds access); the web
264
                        // headless layout uses a fresh (empty) GpuValueCache. An
265
                        // empty map has no entry anyway, and is_empty() is len-based
266
                        // (no probe), so the result is identical on desktop.
267
285667
                        if !cc.has_transform(node_id.index())
268
285381
                            && (self.css_current_transform_values.is_empty()
269
13
                                || !self.css_current_transform_values.contains_key(&node_id))
270
                        {
271
285380
                            return None;
272
287
                        }
273
                    }
274
7
                }
275
294
                let node_data = &node_data[node_id];
276
                // NOT `get_transform(...)?`: a `?` here skips the whole node when there
277
                // is no transform cascade entry (the ordinary case), so a node that just
278
                // LOST its transform never reaches the `(Some(old), None) => Removed` arm
279
                // and its cached TransformKey is never evicted. Turn "no entry" into
280
                // `None` instead (mirrors the transform_origin handling below).
281
294
                let transform_prop =
282
294
                    css_property_cache.get_transform(node_data, &node_id, styled_node_state);
283
294
                let current_transform = transform_prop
284
294
                    .as_ref()
285
294
                    .and_then(|v| v.get_property())
286
294
                    .map(|t| {
287
                        // TODO: look up the parent nodes size properly to resolve animation of
288
                        // transforms with %
289
286
                        let parent_size_width = 0.0;
290
286
                        let parent_size_height = 0.0;
291
286
                        let transform_origin = css_property_cache.get_transform_origin(
292
286
                            node_data,
293
286
                            &node_id,
294
286
                            styled_node_state,
295
                        );
296
286
                        let transform_origin = transform_origin
297
286
                            .as_ref()
298
286
                            .and_then(|o| o.get_property())
299
286
                            .unwrap_or(&default_transform_origin);
300

            
301
286
                        ComputedTransform3D::from_style_transform_vec(
302
286
                            t.as_ref(),
303
286
                            transform_origin,
304
286
                            parent_size_width,
305
286
                            parent_size_height,
306
286
                            RotationMode::ForWebRender,
307
                        )
308
286
                    });
309

            
310
294
                let existing_transform = if self.css_current_transform_values.is_empty() {
311
282
                    None
312
                } else {
313
12
                    self.css_current_transform_values.get(&node_id)
314
                };
315

            
316
294
                match (existing_transform, current_transform) {
317
7
                    (None, None) => None, // no new transform, no old transform
318
275
                    (None, Some(new)) => Some(GpuTransformKeyEvent::Added(
319
275
                        node_id,
320
275
                        TransformKey::unique(),
321
275
                        new,
322
275
                    )),
323
11
                    (Some(old), Some(new)) => Some(GpuTransformKeyEvent::Changed(
324
11
                        node_id,
325
11
                        self.css_transform_keys.get(&node_id).copied()?,
326
11
                        *old,
327
11
                        new,
328
                    )),
329
1
                    (Some(_old), None) => Some(GpuTransformKeyEvent::Removed(
330
1
                        node_id,
331
1
                        self.css_transform_keys.get(&node_id).copied()?,
332
                    )),
333
                }
334
285674
            })
335
5733
            .collect::<Vec<GpuTransformKeyEvent>>();
336

            
337
        // Structural shrink: any cached transform key whose node no longer
338
        // exists in the (smaller) DOM is never visited by the loop above, so it
339
        // would leak on the GPU. Emit an explicit Removed for those.
340
5733
        let node_count = styled_dom.node_data.len();
341
5747
        for (node_id, key) in &self.css_transform_keys {
342
14
            if node_id.index() >= node_count {
343
2
                events.push(GpuTransformKeyEvent::Removed(*node_id, *key));
344
13
            }
345
        }
346

            
347
5733
        events
348
5733
    }
349

            
350
    /// Applies transform key changes (additions/removals) to the cache.
351
5743
    fn apply_transform_events(&mut self, events: &[GpuTransformKeyEvent]) {
352
        // remove / add the CSS transform keys accordingly
353
6045
        for event in events {
354
302
            match &event {
355
281
                GpuTransformKeyEvent::Added(node_id, key, matrix) => {
356
281
                    self.css_transform_keys.insert(*node_id, *key);
357
281
                    self.css_current_transform_values.insert(*node_id, *matrix);
358
281
                }
359
13
                GpuTransformKeyEvent::Changed(node_id, _key, _old_state, new_state) => {
360
13
                    self.css_current_transform_values.insert(*node_id, *new_state);
361
13
                }
362
8
                GpuTransformKeyEvent::Removed(node_id, _key) => {
363
8
                    self.css_transform_keys.remove(node_id);
364
8
                    self.css_current_transform_values.remove(node_id);
365
8
                }
366
            }
367
        }
368
5743
    }
369

            
370
    /// Computes opacity change events against the cached values (read-only).
371
5733
    fn compute_opacity_events(&self, styled_dom: &StyledDom) -> Vec<GpuOpacityKeyEvent> {
372
5733
        let css_property_cache = styled_dom.get_css_property_cache();
373
5733
        let node_data = styled_dom.node_data.as_container();
374
5733
        let node_states = styled_dom.styled_nodes.as_container();
375

            
376
        // calculate the opacity of every single node that has a non-default opacity
377
        //
378
        // GPU fast path: compact cache encodes opacity as a single u8. Nodes with
379
        // no author-set opacity (the common case) have `OPACITY_SENTINEL` and
380
        // return immediately — no cascade walk. Only non-default opacities
381
        // generate key events.
382
5733
        let mut events = (0..styled_dom.node_data.len())
383
285674
            .filter_map(|node_id| {
384
285674
                let node_id = NodeId::new(node_id);
385
285674
                let styled_node_state = &node_states[node_id].styled_node_state;
386

            
387
                // Fast-path opacity read via compact cache.
388
285674
                let mut compact_opacity: Option<f32> = None;
389
285674
                if styled_node_state.is_normal() {
390
285667
                    if let Some(ref cc) = css_property_cache.compact_cache {
391
285667
                        let raw = cc.get_opacity_raw(node_id.index());
392
285667
                        compact_opacity = if raw == azul_css::compact_cache::OPACITY_SENTINEL {
393
                            // unset → default (1.0) — bail out unless we had a prior opacity key
394
285372
                            self.current_opacity_values.get(&node_id)?;
395
1
                            None
396
                        } else {
397
295
                            Some(f32::from(raw) / 254.0)
398
                        };
399
                    }
400
7
                }
401

            
402
303
                let node_data = &node_data[node_id];
403
303
                let current_opacity: Option<f32> = if let Some(v) = compact_opacity {
404
                    // Fast path: value already read from compact cache.
405
295
                    Some(v)
406
8
                } else if styled_node_state.is_normal() && css_property_cache.compact_cache.is_some() {
407
                    // Fast path: sentinel — unset → default (1.0, treated as None here).
408
1
                    None
409
                } else {
410
7
                    css_property_cache
411
7
                        .get_opacity(node_data, &node_id, styled_node_state)?
412
                        .get_property()
413
                        .map(|p| p.inner.normalized())
414
                };
415
296
                let existing_opacity = self.current_opacity_values.get(&node_id);
416

            
417
296
                match (existing_opacity, current_opacity) {
418
                    (None, None) => None, // no new opacity, no old opacity
419
280
                    (None, Some(new)) => Some(GpuOpacityKeyEvent::Added(
420
280
                        node_id,
421
280
                        OpacityKey::unique(),
422
280
                        new,
423
280
                    )),
424
15
                    (Some(old), Some(new)) => Some(GpuOpacityKeyEvent::Changed(
425
15
                        node_id,
426
15
                        self.opacity_keys.get(&node_id).copied()?,
427
15
                        *old,
428
15
                        new,
429
                    )),
430
1
                    (Some(_old), None) => Some(GpuOpacityKeyEvent::Removed(
431
1
                        node_id,
432
1
                        self.opacity_keys.get(&node_id).copied()?,
433
                    )),
434
                }
435
285674
            })
436
5733
            .collect::<Vec<GpuOpacityKeyEvent>>();
437

            
438
        // Structural shrink: emit Removed for cached opacity keys whose node no
439
        // longer exists in the (smaller) DOM (never visited by the loop above).
440
5733
        let node_count = styled_dom.node_data.len();
441
5751
        for (node_id, key) in &self.opacity_keys {
442
18
            if node_id.index() >= node_count {
443
2
                events.push(GpuOpacityKeyEvent::Removed(*node_id, *key));
444
16
            }
445
        }
446

            
447
5733
        events
448
5733
    }
449

            
450
    /// Applies opacity key changes (additions/removals) to the cache.
451
5745
    fn apply_opacity_events(&mut self, events: &[GpuOpacityKeyEvent]) {
452
        // remove / add the opacity keys accordingly
453
6055
        for event in events {
454
310
            match &event {
455
288
                GpuOpacityKeyEvent::Added(node_id, key, opacity) => {
456
288
                    self.opacity_keys.insert(*node_id, *key);
457
288
                    self.current_opacity_values.insert(*node_id, *opacity);
458
288
                }
459
16
                GpuOpacityKeyEvent::Changed(node_id, _key, _old_state, new_state) => {
460
16
                    self.current_opacity_values.insert(*node_id, *new_state);
461
16
                }
462
6
                GpuOpacityKeyEvent::Removed(node_id, _key) => {
463
6
                    self.opacity_keys.remove(node_id);
464
6
                    self.current_opacity_values.remove(node_id);
465
6
                }
466
            }
467
        }
468
5745
    }
469
}
470

            
471
/// Represents a change to a scrollbar opacity key.
472
///
473
/// Scrollbar opacity is managed separately from CSS opacity to enable
474
/// independent fading animations without affecting element opacity.
475
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
476
pub enum GpuScrollbarOpacityEvent {
477
    /// A vertical scrollbar was added to a node
478
    VerticalAdded(DomId, NodeId, OpacityKey, f32),
479
    /// A vertical scrollbar opacity was changed
480
    VerticalChanged(DomId, NodeId, OpacityKey, f32, f32),
481
    /// A vertical scrollbar was removed from a node
482
    VerticalRemoved(DomId, NodeId, OpacityKey),
483
    /// A horizontal scrollbar was added to a node
484
    HorizontalAdded(DomId, NodeId, OpacityKey, f32),
485
    /// A horizontal scrollbar opacity was changed
486
    HorizontalChanged(DomId, NodeId, OpacityKey, f32, f32),
487
    /// A horizontal scrollbar was removed from a node
488
    HorizontalRemoved(DomId, NodeId, OpacityKey),
489
}
490

            
491
/// Contains all GPU-related change events from a cache synchronization.
492
///
493
/// This structure groups transform, opacity, and scrollbar opacity changes together
494
/// for efficient batch processing when updating `WebRender`.
495
#[derive(Default, Debug, Clone, PartialEq, PartialOrd)]
496
pub struct GpuEventChanges {
497
    /// All transform key changes (additions, modifications, removals)
498
    pub transform_key_changes: Vec<GpuTransformKeyEvent>,
499
    /// All opacity key changes (additions, modifications, removals)
500
    pub opacity_key_changes: Vec<GpuOpacityKeyEvent>,
501
    /// All scrollbar opacity key changes (additions, modifications, removals)
502
    pub scrollbar_opacity_changes: Vec<GpuScrollbarOpacityEvent>,
503
}
504

            
505
impl GpuEventChanges {
506
    /// Creates an empty set of GPU event changes.
507
46823
    #[must_use] pub fn empty() -> Self {
508
46823
        Self::default()
509
46823
    }
510

            
511
    /// Returns `true` if there are no transform, opacity, or scrollbar opacity changes.
512
16923
    #[must_use] pub const fn is_empty(&self) -> bool {
513
16923
        self.transform_key_changes.is_empty()
514
16912
            && self.opacity_key_changes.is_empty()
515
16911
            && self.scrollbar_opacity_changes.is_empty()
516
16923
    }
517

            
518
    /// Merges another `GpuEventChanges` into this one, consuming the other.
519
    ///
520
    /// This is useful for combining changes from multiple sources.
521
5714
    pub fn merge(&mut self, other: &mut Self) {
522
5714
        self.transform_key_changes.append(&mut other.transform_key_changes);
523
5714
        self.opacity_key_changes.append(&mut other.opacity_key_changes);
524
5714
        self.scrollbar_opacity_changes.append(&mut other.scrollbar_opacity_changes);
525
5714
    }
526
}
527

            
528
/// Represents a change to a GPU opacity key.
529
///
530
/// These events are generated when synchronizing the cache with the `StyledDom`
531
/// and are used to update `WebRender`'s opacity state efficiently.
532
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
533
pub enum GpuOpacityKeyEvent {
534
    /// A new opacity was added to a node
535
    Added(NodeId, OpacityKey, f32),
536
    /// An existing opacity was modified (includes old and new values)
537
    Changed(NodeId, OpacityKey, f32, f32),
538
    /// An opacity was removed from a node
539
    Removed(NodeId, OpacityKey),
540
}
541

            
542
#[cfg(test)]
543
#[allow(clippy::float_cmp)] // GPU cache values must round-trip bit-exactly, not "approximately"
544
mod autotest_generated {
545
    use azul_css::css::Css;
546

            
547
    use super::*;
548
    use crate::dom::Dom;
549

            
550
    /// A `StyledDom` with exactly one node (the body) and no CSS at all:
551
    /// compact cache present, `has_transform` unset, opacity == `OPACITY_SENTINEL`.
552
    fn plain_styled_dom() -> StyledDom {
553
        let mut dom = Dom::create_body();
554
        StyledDom::create(&mut dom, Css::empty())
555
    }
556

            
557
    /// body > div.<class>, styled by `css_src`.
558
    fn styled_dom_from_css(css_src: &str, class: &str) -> StyledDom {
559
        let css = azul_css::parser2::new_from_str(css_src).0;
560
        let mut dom = Dom::create_body()
561
            .with_children(vec![Dom::create_div().with_class(class.to_string().into())].into());
562
        StyledDom::create(&mut dom, css)
563
    }
564

            
565
    /// A matrix stuffed with every hostile f32 the transform math can produce.
566
    fn hostile_matrix() -> ComputedTransform3D {
567
        ComputedTransform3D::new(
568
            f32::NAN,
569
            f32::INFINITY,
570
            f32::NEG_INFINITY,
571
            f32::MIN,
572
            f32::MAX,
573
            0.0,
574
            -0.0,
575
            f32::EPSILON,
576
            1.0,
577
            2.0,
578
            3.0,
579
            4.0,
580
            5.0,
581
            6.0,
582
            7.0,
583
            8.0,
584
        )
585
    }
586

            
587
    fn scale_matrix(s: f32) -> ComputedTransform3D {
588
        ComputedTransform3D::new(
589
            s, 0.0, 0.0, 0.0, 0.0, s, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
590
        )
591
    }
592

            
593
    // ---------------------------------------------------------------------
594
    // constructors / neutral elements
595
    // ---------------------------------------------------------------------
596

            
597
    #[test]
598
    fn gpu_value_cache_empty_holds_no_keys_and_no_values() {
599
        let cache = GpuValueCache::empty();
600
        assert!(cache.transform_keys.is_empty());
601
        assert!(cache.current_transform_values.is_empty());
602
        assert!(cache.h_transform_keys.is_empty());
603
        assert!(cache.h_current_transform_values.is_empty());
604
        assert!(cache.css_transform_keys.is_empty());
605
        assert!(cache.css_current_transform_values.is_empty());
606
        assert!(cache.opacity_keys.is_empty());
607
        assert!(cache.current_opacity_values.is_empty());
608
        assert!(cache.scrollbar_v_opacity_keys.is_empty());
609
        assert!(cache.scrollbar_h_opacity_keys.is_empty());
610
        assert!(cache.scrollbar_v_opacity_values.is_empty());
611
        assert!(cache.scrollbar_h_opacity_values.is_empty());
612
    }
613

            
614
    #[test]
615
    fn gpu_event_changes_empty_equals_default_and_is_empty() {
616
        let changes = GpuEventChanges::empty();
617
        assert_eq!(changes, GpuEventChanges::default());
618
        assert!(changes.is_empty());
619
        assert_eq!(changes.transform_key_changes.len(), 0);
620
        assert_eq!(changes.opacity_key_changes.len(), 0);
621
        assert_eq!(changes.scrollbar_opacity_changes.len(), 0);
622
    }
623

            
624
    // ---------------------------------------------------------------------
625
    // GpuEventChanges::is_empty (predicate)
626
    // ---------------------------------------------------------------------
627

            
628
    #[test]
629
    fn is_empty_is_false_when_any_single_event_vec_is_populated() {
630
        let node = NodeId::ZERO;
631

            
632
        let mut only_transform = GpuEventChanges::empty();
633
        only_transform
634
            .transform_key_changes
635
            .push(GpuTransformKeyEvent::Removed(node, TransformKey::unique()));
636
        assert!(!only_transform.is_empty());
637

            
638
        let mut only_opacity = GpuEventChanges::empty();
639
        only_opacity
640
            .opacity_key_changes
641
            .push(GpuOpacityKeyEvent::Removed(node, OpacityKey::unique()));
642
        assert!(!only_opacity.is_empty());
643

            
644
        // scrollbar changes alone must also flip the predicate — is_empty() has to
645
        // check all three vecs, not just the two the CSS passes fill.
646
        let mut only_scrollbar = GpuEventChanges::empty();
647
        only_scrollbar
648
            .scrollbar_opacity_changes
649
            .push(GpuScrollbarOpacityEvent::VerticalRemoved(
650
                DomId::ROOT_ID,
651
                node,
652
                OpacityKey::unique(),
653
            ));
654
        assert!(!only_scrollbar.is_empty());
655
    }
656

            
657
    // ---------------------------------------------------------------------
658
    // GpuEventChanges::merge
659
    // ---------------------------------------------------------------------
660

            
661
    #[test]
662
    fn merge_moves_every_event_and_drains_the_source() {
663
        let node = NodeId::new(7);
664

            
665
        let mut target = GpuEventChanges::empty();
666
        target.transform_key_changes.push(GpuTransformKeyEvent::Added(
667
            node,
668
            TransformKey::unique(),
669
            ComputedTransform3D::IDENTITY,
670
        ));
671

            
672
        let mut source = GpuEventChanges::empty();
673
        source
674
            .transform_key_changes
675
            .push(GpuTransformKeyEvent::Removed(node, TransformKey::unique()));
676
        source
677
            .opacity_key_changes
678
            .push(GpuOpacityKeyEvent::Added(node, OpacityKey::unique(), 0.25));
679
        source
680
            .scrollbar_opacity_changes
681
            .push(GpuScrollbarOpacityEvent::HorizontalAdded(
682
                DomId::ROOT_ID,
683
                node,
684
                OpacityKey::unique(),
685
                1.0,
686
            ));
687

            
688
        target.merge(&mut source);
689

            
690
        assert!(source.is_empty(), "merge must consume the source");
691
        assert_eq!(target.transform_key_changes.len(), 2);
692
        assert_eq!(target.opacity_key_changes.len(), 1);
693
        assert_eq!(target.scrollbar_opacity_changes.len(), 1);
694
        // append semantics: self's events keep their position, other's are pushed after.
695
        assert!(matches!(
696
            target.transform_key_changes[0],
697
            GpuTransformKeyEvent::Added(..)
698
        ));
699
        assert!(matches!(
700
            target.transform_key_changes[1],
701
            GpuTransformKeyEvent::Removed(..)
702
        ));
703
    }
704

            
705
    #[test]
706
    fn merge_with_an_empty_set_is_the_identity_in_both_directions() {
707
        let node = NodeId::new(1);
708
        let mut populated = GpuEventChanges::empty();
709
        populated
710
            .opacity_key_changes
711
            .push(GpuOpacityKeyEvent::Added(node, OpacityKey::unique(), 0.5));
712
        let snapshot = populated.clone();
713

            
714
        // x.merge(empty) == x
715
        let mut empty = GpuEventChanges::empty();
716
        populated.merge(&mut empty);
717
        assert_eq!(populated, snapshot);
718
        assert!(empty.is_empty());
719

            
720
        // empty.merge(x) == x
721
        let mut target = GpuEventChanges::empty();
722
        let mut source = snapshot.clone();
723
        target.merge(&mut source);
724
        assert_eq!(target, snapshot);
725
        assert!(source.is_empty());
726
    }
727

            
728
    #[test]
729
    fn merging_large_event_vectors_does_not_panic_and_is_idempotent_when_drained() {
730
        let mut target = GpuEventChanges::empty();
731
        let mut source = GpuEventChanges::empty();
732
        for i in 0..10_000usize {
733
            target
734
                .opacity_key_changes
735
                .push(GpuOpacityKeyEvent::Removed(NodeId::new(i), OpacityKey::unique()));
736
            source
737
                .opacity_key_changes
738
                .push(GpuOpacityKeyEvent::Removed(NodeId::new(i), OpacityKey::unique()));
739
        }
740

            
741
        target.merge(&mut source);
742
        assert_eq!(target.opacity_key_changes.len(), 20_000);
743
        assert!(source.is_empty());
744

            
745
        // merging an already-drained source a second time must be a no-op, not a duplicate
746
        target.merge(&mut source);
747
        assert_eq!(target.opacity_key_changes.len(), 20_000);
748
    }
749

            
750
    // ---------------------------------------------------------------------
751
    // apply_transform_events (private)
752
    // ---------------------------------------------------------------------
753

            
754
    #[test]
755
    fn apply_transform_events_on_an_empty_slice_is_a_noop() {
756
        let mut cache = GpuValueCache::empty();
757
        cache.apply_transform_events(&[]);
758
        assert!(cache.css_transform_keys.is_empty());
759
        assert!(cache.css_current_transform_values.is_empty());
760
    }
761

            
762
    #[test]
763
    fn apply_transform_events_keeps_keys_and_values_in_lockstep() {
764
        let node = NodeId::new(3);
765
        let key = TransformKey::unique();
766
        let mut cache = GpuValueCache::empty();
767

            
768
        cache.apply_transform_events(&[GpuTransformKeyEvent::Added(
769
            node,
770
            key,
771
            ComputedTransform3D::IDENTITY,
772
        )]);
773
        assert_eq!(cache.css_transform_keys.get(&node), Some(&key));
774
        assert_eq!(
775
            cache.css_current_transform_values.get(&node),
776
            Some(&ComputedTransform3D::IDENTITY)
777
        );
778

            
779
        // Changed must swap the value and *keep* the existing key (a new key here
780
        // would orphan the old one on the GPU).
781
        let scaled = scale_matrix(2.0);
782
        cache.apply_transform_events(&[GpuTransformKeyEvent::Changed(
783
            node,
784
            key,
785
            ComputedTransform3D::IDENTITY,
786
            scaled,
787
        )]);
788
        assert_eq!(cache.css_transform_keys.get(&node), Some(&key));
789
        assert_eq!(cache.css_current_transform_values.get(&node), Some(&scaled));
790

            
791
        cache.apply_transform_events(&[GpuTransformKeyEvent::Removed(node, key)]);
792
        assert!(cache.css_transform_keys.is_empty());
793
        assert!(cache.css_current_transform_values.is_empty());
794
    }
795

            
796
    #[test]
797
    fn removing_an_uncached_or_out_of_range_node_does_not_panic() {
798
        let mut cache = GpuValueCache::empty();
799
        // NodeId::new(usize::MAX) is never a valid DOM index — apply_* only hashes it,
800
        // so it must be tolerated rather than used as an array index.
801
        cache.apply_transform_events(&[
802
            GpuTransformKeyEvent::Removed(NodeId::new(usize::MAX), TransformKey::unique()),
803
            GpuTransformKeyEvent::Removed(NodeId::ZERO, TransformKey::unique()),
804
        ]);
805
        cache.apply_opacity_events(&[GpuOpacityKeyEvent::Removed(
806
            NodeId::new(usize::MAX),
807
            OpacityKey::unique(),
808
        )]);
809
        assert!(cache.css_transform_keys.is_empty());
810
        assert!(cache.css_current_transform_values.is_empty());
811
        assert!(cache.opacity_keys.is_empty());
812
        assert!(cache.current_opacity_values.is_empty());
813
    }
814

            
815
    #[test]
816
    fn transform_events_are_applied_in_slice_order_within_one_batch() {
817
        let node = NodeId::new(2);
818
        let first = TransformKey::unique();
819
        let second = TransformKey::unique();
820

            
821
        // Added -> Removed inside one batch must end up removed.
822
        let mut cache = GpuValueCache::empty();
823
        cache.apply_transform_events(&[
824
            GpuTransformKeyEvent::Added(node, first, ComputedTransform3D::IDENTITY),
825
            GpuTransformKeyEvent::Removed(node, first),
826
        ]);
827
        assert!(cache.css_transform_keys.is_empty());
828
        assert!(cache.css_current_transform_values.is_empty());
829

            
830
        // Removed -> Added inside one batch must end up added.
831
        let mut cache = GpuValueCache::empty();
832
        cache.apply_transform_events(&[
833
            GpuTransformKeyEvent::Removed(node, first),
834
            GpuTransformKeyEvent::Added(node, second, ComputedTransform3D::IDENTITY),
835
        ]);
836
        assert_eq!(cache.css_transform_keys.get(&node), Some(&second));
837
    }
838

            
839
    #[test]
840
    fn two_added_events_for_one_node_keep_only_the_last_key() {
841
        let node = NodeId::ZERO;
842
        let first = TransformKey::unique();
843
        let second = TransformKey::unique();
844
        assert_ne!(
845
            first, second,
846
            "TransformKey::unique() must never hand out the same id twice"
847
        );
848

            
849
        let mut cache = GpuValueCache::empty();
850
        cache.apply_transform_events(&[
851
            GpuTransformKeyEvent::Added(node, first, ComputedTransform3D::IDENTITY),
852
            GpuTransformKeyEvent::Added(node, second, scale_matrix(3.0)),
853
        ]);
854
        assert_eq!(cache.css_transform_keys.len(), 1);
855
        assert_eq!(cache.css_transform_keys.get(&node), Some(&second));
856
        assert_eq!(cache.css_current_transform_values.get(&node), Some(&scale_matrix(3.0)));
857
    }
858

            
859
    #[test]
860
    fn a_changed_event_for_an_uncached_node_inserts_a_value_but_no_key() {
861
        // compute_transform_events can never emit this (it `?`s on an existing key),
862
        // but apply_transform_events takes an arbitrary slice and must not panic.
863
        let node = NodeId::new(5);
864
        let mut cache = GpuValueCache::empty();
865
        cache.apply_transform_events(&[GpuTransformKeyEvent::Changed(
866
            node,
867
            TransformKey::unique(),
868
            ComputedTransform3D::IDENTITY,
869
            scale_matrix(2.0),
870
        )]);
871
        assert_eq!(cache.css_current_transform_values.get(&node), Some(&scale_matrix(2.0)));
872
        assert!(
873
            cache.css_transform_keys.is_empty(),
874
            "Changed only writes the value map; the key map stays untouched"
875
        );
876
    }
877

            
878
    #[test]
879
    fn non_finite_matrices_are_stored_verbatim_and_never_compare_equal() {
880
        let node = NodeId::new(1);
881
        let mut cache = GpuValueCache::empty();
882
        cache.apply_transform_events(&[GpuTransformKeyEvent::Added(
883
            node,
884
            TransformKey::unique(),
885
            hostile_matrix(),
886
        )]);
887

            
888
        let stored = cache
889
            .css_current_transform_values
890
            .get(&node)
891
            .copied()
892
            .expect("the matrix must be cached even when it is full of NaN/Inf");
893
        assert!(stored.m[0][0].is_nan());
894
        assert!(stored.m[0][1].is_infinite() && stored.m[0][1].is_sign_positive());
895
        assert!(stored.m[0][2].is_infinite() && stored.m[0][2].is_sign_negative());
896
        assert_eq!(stored.m[0][3], f32::MIN);
897
        assert_eq!(stored.m[1][0], f32::MAX);
898

            
899
        // Consequence worth pinning: a NaN matrix is not PartialEq-equal to itself, so
900
        // no caller may use `old == new` to suppress a redundant GPU update.
901
        let a = hostile_matrix();
902
        let b = hostile_matrix();
903
        assert_ne!(a, b);
904
    }
905

            
906
    // ---------------------------------------------------------------------
907
    // apply_opacity_events (private)
908
    // ---------------------------------------------------------------------
909

            
910
    #[test]
911
    fn apply_opacity_events_add_change_remove_round_trip() {
912
        let node = NodeId::new(4);
913
        let key = OpacityKey::unique();
914
        let mut cache = GpuValueCache::empty();
915

            
916
        cache.apply_opacity_events(&[GpuOpacityKeyEvent::Added(node, key, 0.25)]);
917
        assert_eq!(cache.opacity_keys.get(&node), Some(&key));
918
        assert_eq!(cache.current_opacity_values.get(&node), Some(&0.25));
919

            
920
        cache.apply_opacity_events(&[GpuOpacityKeyEvent::Changed(node, key, 0.25, 0.75)]);
921
        assert_eq!(cache.opacity_keys.get(&node), Some(&key));
922
        assert_eq!(cache.current_opacity_values.get(&node), Some(&0.75));
923

            
924
        cache.apply_opacity_events(&[GpuOpacityKeyEvent::Removed(node, key)]);
925
        assert!(cache.opacity_keys.is_empty());
926
        assert!(cache.current_opacity_values.is_empty());
927

            
928
        // a second Removed for the same node is a no-op, not a panic
929
        cache.apply_opacity_events(&[GpuOpacityKeyEvent::Removed(node, key)]);
930
        assert!(cache.opacity_keys.is_empty());
931
    }
932

            
933
    #[test]
934
    fn apply_opacity_events_stores_out_of_range_values_verbatim() {
935
        // The cache does no clamping of its own — pin that, so a future "helpful"
936
        // clamp shows up as a test change rather than a silent behaviour change.
937
        let cases = [
938
            f32::NAN,
939
            f32::INFINITY,
940
            f32::NEG_INFINITY,
941
            -1.0,
942
            2.0,
943
            1e30,
944
            -0.0,
945
        ];
946
        let mut cache = GpuValueCache::empty();
947
        for (i, value) in cases.iter().enumerate() {
948
            cache.apply_opacity_events(&[GpuOpacityKeyEvent::Added(
949
                NodeId::new(i),
950
                OpacityKey::unique(),
951
                *value,
952
            )]);
953
        }
954

            
955
        assert_eq!(cache.current_opacity_values.len(), cases.len());
956
        assert_eq!(cache.opacity_keys.len(), cache.current_opacity_values.len());
957
        assert!(cache.current_opacity_values[&NodeId::new(0)].is_nan());
958
        assert!(cache.current_opacity_values[&NodeId::new(1)].is_infinite());
959
        assert_eq!(cache.current_opacity_values[&NodeId::new(3)], -1.0);
960
        assert_eq!(cache.current_opacity_values[&NodeId::new(4)], 2.0);
961
        assert_eq!(cache.current_opacity_values[&NodeId::new(5)], 1e30);
962
    }
963

            
964
    // ---------------------------------------------------------------------
965
    // init_simd_features (private)
966
    // ---------------------------------------------------------------------
967

            
968
    #[test]
969
    fn init_simd_features_is_idempotent() {
970
        GpuValueCache::init_simd_features();
971
        GpuValueCache::init_simd_features();
972

            
973
        #[cfg(target_arch = "x86_64")]
974
        {
975
            assert!(
976
                INITIALIZED.load(AtomicOrdering::SeqCst),
977
                "the one-time init flag must be set after the first call"
978
            );
979
            let sse = USE_SSE.load(AtomicOrdering::SeqCst);
980
            let avx = USE_AVX.load(AtomicOrdering::SeqCst);
981
            GpuValueCache::init_simd_features();
982
            assert_eq!(sse, USE_SSE.load(AtomicOrdering::SeqCst));
983
            assert_eq!(avx, USE_AVX.load(AtomicOrdering::SeqCst));
984
        }
985
    }
986

            
987
    // ---------------------------------------------------------------------
988
    // synchronize / compute_* (against a real StyledDom)
989
    // ---------------------------------------------------------------------
990

            
991
    #[test]
992
    fn synchronize_on_a_transform_and_opacity_free_dom_emits_nothing() {
993
        let styled = plain_styled_dom();
994
        let mut cache = GpuValueCache::empty();
995

            
996
        let changes = cache.synchronize(&styled);
997

            
998
        assert!(changes.is_empty());
999
        assert!(cache.css_transform_keys.is_empty());
        assert!(cache.opacity_keys.is_empty());
    }
    #[test]
    fn synchronize_never_fills_scrollbar_opacity_changes() {
        // Documented contract: scrollbar opacity is filled by a *separate* pass, so
        // synchronize() must leave that vec empty (and not touch the scrollbar maps).
        let styled = plain_styled_dom();
        let mut cache = GpuValueCache::empty();
        cache
            .scrollbar_v_opacity_keys
            .insert((DomId::ROOT_ID, NodeId::ZERO), OpacityKey::unique());
        cache
            .scrollbar_v_opacity_values
            .insert((DomId::ROOT_ID, NodeId::ZERO), 0.5);
        let changes = cache.synchronize(&styled);
        assert!(changes.scrollbar_opacity_changes.is_empty());
        assert_eq!(cache.scrollbar_v_opacity_keys.len(), 1);
        assert_eq!(cache.scrollbar_v_opacity_values.len(), 1);
    }
    #[test]
    fn opacity_round_trips_from_css_through_the_compact_cache_quantizer() {
        // compact.rs encodes opacity as `(o * 254.0).round() as u8`; gpu.rs decodes it
        // as `raw / 254.0`. Every value must survive that round-trip within one step,
        // and must never leave [0, 1] (which WebRender would reject).
        const STEP: f32 = 1.0 / 254.0;
        for (css_value, expected) in [
            ("0", 0.0f32),
            ("0.25", 0.25),
            ("0.5", 0.5),
            ("1", 1.0),
            ("50%", 0.5),
            ("100%", 1.0),
        ] {
            let styled = styled_dom_from_css(&format!(".fade {{ opacity: {css_value}; }}"), "fade");
            let mut cache = GpuValueCache::empty();
            let changes = cache.synchronize(&styled);
            let decoded = changes
                .opacity_key_changes
                .iter()
                .find_map(|e| match e {
                    GpuOpacityKeyEvent::Added(_, _, v) => Some(*v),
                    _ => None,
                })
                .unwrap_or_else(|| panic!("`opacity: {css_value}` produced no Added event"));
            assert!(
                (decoded - expected).abs() <= STEP,
                "`opacity: {css_value}` decoded as {decoded}, expected ~{expected}"
            );
            assert!(
                (0.0..=1.0).contains(&decoded),
                "`opacity: {css_value}` decoded to {decoded}, outside [0, 1]"
            );
        }
    }
    #[test]
    fn re_synchronizing_an_unchanged_dom_never_mints_a_second_key() {
        let styled = styled_dom_from_css(".fade { opacity: 0.5; }", "fade");
        let mut cache = GpuValueCache::empty();
        let first = cache.synchronize(&styled);
        let added_first = first
            .opacity_key_changes
            .iter()
            .filter(|e| matches!(e, GpuOpacityKeyEvent::Added(..)))
            .count();
        assert_eq!(added_first, 1, "the .fade div must get exactly one opacity key");
        let keys_after_first = cache.opacity_keys.clone();
        let second = cache.synchronize(&styled);
        assert!(
            !second
                .opacity_key_changes
                .iter()
                .any(|e| matches!(e, GpuOpacityKeyEvent::Added(..))),
            "re-syncing an unchanged DOM must not allocate a new OpacityKey (GPU key leak)"
        );
        assert_eq!(
            cache.opacity_keys, keys_after_first,
            "the OpacityKey of a node must be stable across syncs"
        );
    }
    #[test]
    fn synchronize_evicts_cached_keys_for_nodes_that_no_longer_exist() {
        // Structural shrink: the DOM got smaller, so cached keys for now-missing nodes
        // are never visited by the per-node loop and would leak on the GPU.
        let styled = plain_styled_dom();
        assert_eq!(styled.node_data.len(), 1);
        let ghost_a = NodeId::new(9_999);
        let ghost_b = NodeId::new(usize::MAX); // must be "out of range", not an overflow
        let mut cache = GpuValueCache::empty();
        for ghost in [ghost_a, ghost_b] {
            cache.css_transform_keys.insert(ghost, TransformKey::unique());
            cache
                .css_current_transform_values
                .insert(ghost, ComputedTransform3D::IDENTITY);
            cache.opacity_keys.insert(ghost, OpacityKey::unique());
            cache.current_opacity_values.insert(ghost, 0.5);
        }
        let changes = cache.synchronize(&styled);
        assert_eq!(changes.transform_key_changes.len(), 2);
        assert_eq!(changes.opacity_key_changes.len(), 2);
        assert!(changes
            .transform_key_changes
            .iter()
            .all(|e| matches!(e, GpuTransformKeyEvent::Removed(..))));
        assert!(changes
            .opacity_key_changes
            .iter()
            .all(|e| matches!(e, GpuOpacityKeyEvent::Removed(..))));
        assert!(cache.css_transform_keys.is_empty());
        assert!(cache.css_current_transform_values.is_empty());
        assert!(cache.opacity_keys.is_empty());
        assert!(cache.current_opacity_values.is_empty());
    }
    #[test]
    fn a_cached_opacity_is_evicted_when_the_node_loses_its_opacity() {
        // Node still exists, but the DOM no longer sets `opacity` on it: the
        // (Some(old), None) arm must fire a Removed so the OpacityKey is freed.
        let styled = plain_styled_dom();
        let node = NodeId::ZERO;
        let key = OpacityKey::unique();
        let mut cache = GpuValueCache::empty();
        cache.opacity_keys.insert(node, key);
        cache.current_opacity_values.insert(node, 0.5);
        let changes = cache.synchronize(&styled);
        assert_eq!(
            changes.opacity_key_changes,
            vec![GpuOpacityKeyEvent::Removed(node, key)]
        );
        assert!(cache.opacity_keys.is_empty());
        assert!(cache.current_opacity_values.is_empty());
    }
    #[test]
    fn a_cached_transform_is_evicted_when_the_node_loses_its_transform() {
        // Same scenario as the opacity test above, for transforms: the node is still in
        // the DOM but no longer carries a `transform` property (e.g. its class was
        // dropped between frames). The (Some(old), None) arm of compute_transform_events
        // must fire a Removed — otherwise the TransformKey leaks on the GPU and the
        // cache keeps serving a stale matrix forever.
        let styled = plain_styled_dom();
        let node = NodeId::ZERO;
        let key = TransformKey::unique();
        let mut cache = GpuValueCache::empty();
        cache.css_transform_keys.insert(node, key);
        cache
            .css_current_transform_values
            .insert(node, ComputedTransform3D::IDENTITY);
        let changes = cache.synchronize(&styled);
        assert_eq!(
            changes.transform_key_changes,
            vec![GpuTransformKeyEvent::Removed(node, key)]
        );
        assert!(
            cache.css_transform_keys.is_empty(),
            "a stale CSS transform key was not evicted"
        );
        assert!(
            cache.css_current_transform_values.is_empty(),
            "a stale CSS transform value was not evicted"
        );
    }
    #[test]
    fn synchronize_survives_malformed_and_unicode_css() {
        for css_src in [
            "",
            ".fade { opacity: ; }",
            ".fade { opacity: 🦀; }",
            ".fade { opacity: -1; }",
            ".fade { opacity: 99999999999; }",
            ".fade { opacity: NaN; }",
            ".fade { transform: }",
            ".fade { transform: rotate(NaN); }",
            ".fade { transform: rotate(1e400deg); }",
            ".fade { transform: rotate(); }",
        ] {
            let styled = styled_dom_from_css(css_src, "fade");
            let mut cache = GpuValueCache::empty();
            let _ = cache.synchronize(&styled);
            // Whatever the parser salvaged, the cache invariants must survive it.
            assert_eq!(
                cache.css_transform_keys.len(),
                cache.css_current_transform_values.len(),
                "transform key/value maps drifted apart (css: {css_src:?})"
            );
            assert_eq!(
                cache.opacity_keys.len(),
                cache.current_opacity_values.len(),
                "opacity key/value maps drifted apart (css: {css_src:?})"
            );
            for value in cache.current_opacity_values.values() {
                assert!(
                    !value.is_nan(),
                    "a NaN opacity reached the GPU cache (css: {css_src:?})"
                );
                assert!(
                    (0.0..=1.0).contains(value),
                    "an out-of-range opacity ({value}) reached the GPU cache (css: {css_src:?})"
                );
            }
        }
    }
    #[test]
    fn synchronize_on_a_500_node_dom_keeps_every_key_in_range() {
        let css = azul_css::parser2::new_from_str(
            ".t { transform: rotate(45deg); } .o { opacity: 0.25; }",
        )
        .0;
        let children = (0..500usize)
            .map(|i| {
                let class = if i % 2 == 0 { "t" } else { "o" };
                Dom::create_div().with_class(class.to_string().into())
            })
            .collect::<Vec<Dom>>();
        let mut dom = Dom::create_body().with_children(children.into());
        let styled = StyledDom::create(&mut dom, css);
        let mut cache = GpuValueCache::empty();
        let changes = cache.synchronize(&styled);
        // On a fresh cache every event has to be an Added, so the event count and the
        // resulting key count must agree exactly.
        assert_eq!(
            changes.transform_key_changes.len(),
            cache.css_transform_keys.len()
        );
        assert_eq!(changes.opacity_key_changes.len(), cache.opacity_keys.len());
        assert_eq!(
            cache.css_transform_keys.len(),
            cache.css_current_transform_values.len()
        );
        assert_eq!(cache.opacity_keys.len(), cache.current_opacity_values.len());
        // No cached key may point outside the DOM.
        let node_count = styled.node_data.len();
        assert!(cache.css_transform_keys.keys().all(|n| n.index() < node_count));
        assert!(cache.opacity_keys.keys().all(|n| n.index() < node_count));
    }
}