Skip to main content

azul_layout/managers/
gpu_state.rs

1//! Centralized GPU state management.
2//!
3//! This module provides management of GPU property keys
4//! (opacity, transforms, etc.), fade-in/fade-out animations
5//! for scrollbar opacity - as a single source of truth for
6//! the GPU cache.
7
8use alloc::collections::BTreeMap;
9
10#[cfg(feature = "std")]
11use std::collections::HashMap;
12#[cfg(not(feature = "std"))]
13use alloc::collections::BTreeMap as HashMap;
14
15use azul_core::{
16    dom::{DomId, NodeId},
17    dom::ScrollbarOrientation,
18    geom::{LogicalPosition, LogicalRect, LogicalSize},
19    gpu::{GpuEventChanges, GpuTransformKeyEvent, GpuValueCache},
20    resources::TransformKey,
21    task::{Duration, SystemTimeDiff},
22    transform::ComputedTransform3D,
23};
24
25use crate::{
26    managers::scroll_state::ScrollManager,
27    solver3::{
28        fc::DEFAULT_SCROLLBAR_WIDTH_PX,
29        layout_tree::LayoutTree,
30        scrollbar::compute_scrollbar_geometry_with_button_size,
31    },
32};
33
34/// Default delay before scrollbars start fading out (500ms)
35pub const DEFAULT_FADE_DELAY_MS: u64 = 500;
36/// Default duration of scrollbar fade-out animation (200ms)
37pub const DEFAULT_FADE_DURATION_MS: u64 = 200;
38
39/// Manages GPU-accelerated properties across all DOMs.
40///
41/// The `GpuStateManager` maintains caches for transform and opacity keys
42/// that are used by the GPU renderer. It handles:
43///
44/// - Scrollbar thumb position transforms (updated on scroll)
45/// - Opacity fading for scrollbars (fade in on activity, fade out after delay)
46/// - Per-DOM GPU value caches for efficient rendering
47#[derive(Debug, Clone)]
48pub struct GpuStateManager {
49    /// GPU value caches indexed by DOM ID
50    pub caches: BTreeMap<DomId, GpuValueCache>,
51    /// Delay before scrollbars start fading out after last activity
52    pub fade_delay: Duration,
53    /// Duration of the fade-out animation
54    pub fade_duration: Duration,
55    /// Whether any scrollbar has non-zero opacity and needs continued frame
56    /// generation. Set during both the `fade_delay` period (opacity == 1.0)
57    /// and the active fade-out phase (0 < opacity < 1).
58    /// Set by `LayoutWindow::synchronize_scrollbar_opacity`, read by the platform render loop.
59    pub scrollbar_fade_active: bool,
60    /// GPU events produced during layout (CSS transform / opacity synchronization,
61    /// scrollbar transform / opacity updates) that have not yet been pushed to
62    /// the renderer. Drained by the platform render path when a transaction is
63    /// built.
64    pub pending_changes: GpuEventChanges,
65}
66
67impl Default for GpuStateManager {
68    fn default() -> Self {
69        Self::new(
70            Duration::System(SystemTimeDiff::from_millis(DEFAULT_FADE_DELAY_MS)),
71            Duration::System(SystemTimeDiff::from_millis(DEFAULT_FADE_DURATION_MS)),
72        )
73    }
74}
75
76impl GpuStateManager {
77    /// Creates a new GPU state manager with specified fade timing.
78    #[must_use] pub fn new(fade_delay: Duration, fade_duration: Duration) -> Self {
79        Self {
80            caches: BTreeMap::new(),
81            fade_delay,
82            fade_duration,
83            scrollbar_fade_active: false,
84            pending_changes: GpuEventChanges::empty(),
85        }
86    }
87
88    /// Take any queued transform / opacity events that have been accumulated
89    /// during layout. Clears the internal buffer.
90    pub fn take_pending_changes(&mut self) -> GpuEventChanges {
91        core::mem::take(&mut self.pending_changes)
92    }
93
94    // NOTE: the per-frame scrollbar-fade interpolation lives in
95    // `LayoutWindow::synchronize_scrollbar_opacity` (layout/src/window.rs),
96    // which reads the last-activity time straight from the `ScrollManager` and
97    // is the single source of truth for fade opacity. An earlier duplicate
98    // tick-based subsystem here (`tick` / `record_scroll_activity` /
99    // `calculate_fade_opacity` + a `fade_states` map) was never wired into any
100    // render loop and has been removed.
101
102    /// Gets or creates the GPU cache for a specific DOM.
103    #[must_use] pub fn get_cache(&self, dom_id: DomId) -> Option<&GpuValueCache> {
104        self.caches.get(&dom_id)
105    }
106
107    pub fn get_or_create_cache(&mut self, dom_id: DomId) -> &mut GpuValueCache {
108        self.caches.entry(dom_id).or_default()
109    }
110
111    /// Updates scrollbar thumb transforms based on current scroll positions.
112    ///
113    /// Calculates the transform needed to position scrollbar thumbs correctly
114    /// based on the scroll offset and content/container sizes. Returns the
115    /// GPU event changes that need to be applied by the renderer.
116    pub fn update_scrollbar_transforms(
117        &mut self,
118        dom_id: DomId,
119        scroll_manager: &ScrollManager,
120        layout_tree: &LayoutTree,
121    ) -> GpuEventChanges {
122        let mut changes = GpuEventChanges::empty();
123        let gpu_cache = self.get_or_create_cache(dom_id);
124
125        for (node_idx, node) in layout_tree.nodes.iter().enumerate() {
126            let warm = layout_tree.warm(node_idx);
127            let Some(scrollbar_info) = warm.and_then(|w| w.scrollbar_info.as_ref()) else {
128                continue;
129            };
130            let Some(node_id) = node.dom_node_id else {
131                continue;
132            };
133
134            let scroll_offset = scroll_manager
135                .get_current_offset(dom_id, node_id)
136                .unwrap_or_default();
137
138            // Compute inner_rect (padding-box) by subtracting borders from used_size
139            let border_box_size = node.used_size.unwrap_or_default();
140            let nbp = node.box_props.unpack();
141            let border = &nbp.border;
142            let inner_size = LogicalSize {
143                width: (border_box_size.width - border.left - border.right).max(0.0),
144                height: (border_box_size.height - border.top - border.bottom).max(0.0),
145            };
146            // Use zero origin since we only need the geometry ratios, not absolute position
147            let inner_rect = LogicalRect {
148                origin: LogicalPosition::new(0.0, 0.0),
149                size: inner_size,
150            };
151
152            // Use get_content_size() as the single source of truth for content dimensions
153            let content_size = layout_tree.get_content_size(node_idx);
154
155            if scrollbar_info.needs_vertical {
156                // Use the visual width from the scrollbar style — same value used
157                // by display_list.rs to paint the scrollbar. For overlay scrollbars,
158                // visual_width_px is non-zero (e.g. 8.0) even though the layout-
159                // reserved width (scrollbar_height) is 0.0.
160                let is_overlay = scrollbar_info.scrollbar_height == 0.0;
161                let scrollbar_width_px = if scrollbar_info.visual_width_px > 0.0 {
162                    scrollbar_info.visual_width_px
163                } else if !is_overlay {
164                    scrollbar_info.scrollbar_height
165                } else {
166                    DEFAULT_SCROLLBAR_WIDTH_PX
167                };
168                // Overlay scrollbars (macOS-style) have no arrow buttons
169                let button_size = if is_overlay { 0.0 } else { scrollbar_width_px };
170
171                let v_geom = compute_scrollbar_geometry_with_button_size(
172                    ScrollbarOrientation::Vertical,
173                    inner_rect,
174                    content_size,
175                    scroll_offset.y,
176                    scrollbar_width_px,
177                    scrollbar_info.needs_horizontal,
178                    button_size,
179                );
180
181                let transform =
182                    ComputedTransform3D::new_translation(0.0, v_geom.thumb_offset, 0.0);
183                update_scrollbar_transform_key(gpu_cache, &mut changes, node_id, transform, ScrollbarOrientation::Vertical);
184            }
185
186            if scrollbar_info.needs_horizontal {
187                let is_overlay = scrollbar_info.scrollbar_width == 0.0;
188                let scrollbar_width_px = if scrollbar_info.visual_width_px > 0.0 {
189                    scrollbar_info.visual_width_px
190                } else if !is_overlay {
191                    scrollbar_info.scrollbar_width
192                } else {
193                    DEFAULT_SCROLLBAR_WIDTH_PX
194                };
195                let button_size = if is_overlay { 0.0 } else { scrollbar_width_px };
196
197                let h_geom = compute_scrollbar_geometry_with_button_size(
198                    ScrollbarOrientation::Horizontal,
199                    inner_rect,
200                    content_size,
201                    scroll_offset.x,
202                    scrollbar_width_px,
203                    scrollbar_info.needs_vertical,
204                    button_size,
205                );
206
207                let transform =
208                    ComputedTransform3D::new_translation(h_geom.thumb_offset, 0.0, 0.0);
209                update_scrollbar_transform_key(gpu_cache, &mut changes, node_id, transform, ScrollbarOrientation::Horizontal);
210            }
211        }
212
213        changes
214    }
215}
216
217/// Updates or creates a scrollbar transform key in the GPU cache for the given orientation.
218fn update_scrollbar_transform_key(
219    gpu_cache: &mut GpuValueCache,
220    changes: &mut GpuEventChanges,
221    node_id: NodeId,
222    transform: ComputedTransform3D,
223    orientation: ScrollbarOrientation,
224) {
225    let (keys, values) = match orientation {
226        ScrollbarOrientation::Vertical => (
227            &mut gpu_cache.transform_keys,
228            &mut gpu_cache.current_transform_values,
229        ),
230        ScrollbarOrientation::Horizontal => (
231            &mut gpu_cache.h_transform_keys,
232            &mut gpu_cache.h_current_transform_values,
233        ),
234    };
235
236    if let Some(existing_transform) = values.get(&node_id) {
237        if *existing_transform != transform {
238            let Some(&transform_key) = keys.get(&node_id) else {
239                return;
240            };
241            changes
242                .transform_key_changes
243                .push(GpuTransformKeyEvent::Changed(
244                    node_id,
245                    transform_key,
246                    *existing_transform,
247                    transform,
248                ));
249            values.insert(node_id, transform);
250        }
251    } else {
252        let transform_key = TransformKey::unique();
253        keys.insert(node_id, transform_key);
254        values.insert(node_id, transform);
255        changes
256            .transform_key_changes
257            .push(GpuTransformKeyEvent::Added(
258                node_id,
259                transform_key,
260                transform,
261            ));
262    }
263}
264
265impl crate::managers::NodeIdRemap for GpuStateManager {
266    /// Remap the per-node GPU caches (transform / opacity keys and values).
267    ///
268    /// Without this, a rebuild that shifts `NodeIds` left the scrollbar/CSS
269    /// transform + opacity keys attached to the wrong node — the visible symptom
270    /// being a scrollbar thumb (or an animated element) that keeps painting at a
271    /// stale offset, plus `scrollbar_fade_active` never settling because the
272    /// platform loop keeps generating frames for a node that is not the one
273    /// actually scrolling.
274    fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
275        let Some(cache) = self.caches.get_mut(&dom) else {
276            return;
277        };
278
279        remap_hashmap(&mut cache.transform_keys, map);
280        remap_hashmap(&mut cache.current_transform_values, map);
281        remap_hashmap(&mut cache.h_transform_keys, map);
282        remap_hashmap(&mut cache.h_current_transform_values, map);
283        remap_hashmap(&mut cache.css_transform_keys, map);
284        remap_hashmap(&mut cache.css_current_transform_values, map);
285        remap_hashmap(&mut cache.opacity_keys, map);
286        remap_hashmap(&mut cache.current_opacity_values, map);
287        remap_dom_hashmap(&mut cache.scrollbar_v_opacity_keys, dom, map);
288        remap_dom_hashmap(&mut cache.scrollbar_h_opacity_keys, dom, map);
289        remap_dom_hashmap(&mut cache.scrollbar_v_opacity_values, dom, map);
290        remap_dom_hashmap(&mut cache.scrollbar_h_opacity_values, dom, map);
291    }
292}
293
294/// Rewrite `NodeId` keys, dropping entries for unmounted nodes.
295fn remap_hashmap<V>(map: &mut HashMap<NodeId, V>, node_map: &crate::managers::NodeIdMap) {
296    let old = core::mem::take(map);
297    for (old_id, v) in old {
298        if let Some(new_id) = node_map.resolve(old_id) {
299            map.insert(new_id, v);
300        }
301    }
302}
303
304/// Rewrite `(DomId, NodeId)` keys for `dom` only, dropping unmounted nodes.
305fn remap_dom_hashmap<V>(
306    map: &mut HashMap<(DomId, NodeId), V>,
307    dom: DomId,
308    node_map: &crate::managers::NodeIdMap,
309) {
310    let old = core::mem::take(map);
311    for ((d, old_id), v) in old {
312        if d != dom {
313            map.insert((d, old_id), v);
314        } else if let Some(new_id) = node_map.resolve(old_id) {
315            map.insert((d, new_id), v);
316        }
317    }
318}
319
320#[cfg(test)]
321mod autotest_generated {
322    use azul_core::{
323        dom::FormattingContext,
324        resources::OpacityKey,
325        task::{Instant, SystemTick, SystemTickDiff},
326    };
327
328    use super::*;
329    use crate::{
330        managers::{NodeIdMap, NodeIdRemap},
331        solver3::{
332            geometry::PackedBoxProps,
333            layout_tree::{LayoutNodeCold, LayoutNodeHot, LayoutNodeWarm},
334            scrollbar::ScrollbarRequirements,
335        },
336    };
337
338    // ------------------------------------------------------------------
339    // Fixtures
340    // ------------------------------------------------------------------
341
342    fn dom(inner: usize) -> DomId {
343        DomId { inner }
344    }
345
346    fn t0() -> Instant {
347        Instant::Tick(SystemTick::new(0))
348    }
349
350    fn millis(ms: u64) -> Duration {
351        Duration::System(SystemTimeDiff::from_millis(ms))
352    }
353
354    /// Translation transform — the only shape `update_scrollbar_transforms`
355    /// ever produces.
356    fn tx(x: f32, y: f32) -> ComputedTransform3D {
357        ComputedTransform3D::new_translation(x, y, 0.0)
358    }
359
360    fn hot(dom_node_id: Option<NodeId>, used: Option<LogicalSize>) -> LayoutNodeHot {
361        LayoutNodeHot {
362            box_props: PackedBoxProps::default(),
363            dom_node_id,
364            used_size: used,
365            formatting_context: FormattingContext::Block {
366                establishes_new_context: true,
367            },
368            parent: None,
369        }
370    }
371
372    fn warm_node(
373        scrollbar_info: Option<ScrollbarRequirements>,
374        content: Option<LogicalSize>,
375    ) -> LayoutNodeWarm {
376        LayoutNodeWarm {
377            scrollbar_info,
378            overflow_content_size: content,
379            ..Default::default()
380        }
381    }
382
383    /// A classic (space-reserving) vertical scrollbar with an explicit visual
384    /// width — the un-ambiguous case where no field-fallback logic kicks in.
385    fn v_scrollbar() -> ScrollbarRequirements {
386        ScrollbarRequirements {
387            needs_horizontal: false,
388            needs_vertical: true,
389            scrollbar_width: 16.0,
390            scrollbar_height: 16.0,
391            visual_width_px: 16.0,
392        }
393    }
394
395    fn h_scrollbar() -> ScrollbarRequirements {
396        ScrollbarRequirements {
397            needs_horizontal: true,
398            needs_vertical: false,
399            scrollbar_width: 16.0,
400            scrollbar_height: 16.0,
401            visual_width_px: 16.0,
402        }
403    }
404
405    fn tree(nodes: Vec<LayoutNodeHot>, warm: Vec<LayoutNodeWarm>) -> LayoutTree {
406        let n = nodes.len();
407        LayoutTree {
408            nodes,
409            warm,
410            cold: vec![LayoutNodeCold::default(); n],
411            root: 0,
412            dom_to_layout: BTreeMap::new(),
413            children_arena: Vec::new(),
414            children_offsets: vec![(0, 0); n],
415            subtree_needs_intrinsic: Vec::new(),
416        }
417    }
418
419    /// Single scrollable node: 100×100 border-box, `content` content-box.
420    fn one_node_tree(sb: ScrollbarRequirements, content: LogicalSize) -> LayoutTree {
421        tree(
422            vec![hot(
423                Some(NodeId::new(1)),
424                Some(LogicalSize::new(100.0, 100.0)),
425            )],
426            vec![warm_node(Some(sb), Some(content))],
427        )
428    }
429
430    /// The y-translation of the single transform event emitted, if any.
431    fn sole_added_y(changes: &GpuEventChanges) -> f32 {
432        assert_eq!(changes.transform_key_changes.len(), 1);
433        match changes.transform_key_changes[0] {
434            GpuTransformKeyEvent::Added(_, _, t) => t.m[3][1],
435            ref other => panic!("expected Added, got {other:?}"),
436        }
437    }
438
439    fn sole_added_x(changes: &GpuEventChanges) -> f32 {
440        assert_eq!(changes.transform_key_changes.len(), 1);
441        match changes.transform_key_changes[0] {
442            GpuTransformKeyEvent::Added(_, _, t) => t.m[3][0],
443            ref other => panic!("expected Added, got {other:?}"),
444        }
445    }
446
447    // ------------------------------------------------------------------
448    // GpuStateManager::new / Default — constructor invariants
449    // ------------------------------------------------------------------
450
451    #[test]
452    fn new_stores_both_durations_and_starts_empty_and_idle() {
453        let m = GpuStateManager::new(millis(1), millis(2));
454        assert_eq!(m.fade_delay, millis(1));
455        assert_eq!(m.fade_duration, millis(2));
456        assert!(m.caches.is_empty());
457        assert!(!m.scrollbar_fade_active);
458        assert!(m.pending_changes.is_empty());
459    }
460
461    #[test]
462    fn new_survives_zero_and_u64_max_durations_without_panicking() {
463        // Zero fade window: the fade math elsewhere divides by fade_duration,
464        // so a 0 duration must at least be constructible without panicking here.
465        let zero = GpuStateManager::new(millis(0), millis(0));
466        assert_eq!(zero.fade_delay, millis(0));
467        assert_eq!(zero.fade_duration, millis(0));
468
469        // from_millis(u64::MAX) is ~584 million years — no overflow, no panic.
470        let huge = GpuStateManager::new(millis(u64::MAX), millis(u64::MAX));
471        assert_eq!(huge.fade_delay, millis(u64::MAX));
472        assert!(huge.caches.is_empty());
473    }
474
475    #[test]
476    fn new_accepts_tick_durations_not_just_system_durations() {
477        // Duration is an enum; the constructor must not assume the System variant.
478        let tick = Duration::Tick(SystemTickDiff { tick_diff: u64::MAX });
479        let m = GpuStateManager::new(tick, tick);
480        assert_eq!(m.fade_delay, tick);
481        assert_eq!(m.fade_duration, tick);
482    }
483
484    #[test]
485    fn default_matches_the_documented_fade_constants() {
486        let m = GpuStateManager::default();
487        assert_eq!(m.fade_delay, millis(DEFAULT_FADE_DELAY_MS));
488        assert_eq!(m.fade_duration, millis(DEFAULT_FADE_DURATION_MS));
489        assert_eq!(DEFAULT_FADE_DELAY_MS, 500);
490        assert_eq!(DEFAULT_FADE_DURATION_MS, 200);
491    }
492
493    // ------------------------------------------------------------------
494    // take_pending_changes
495    // ------------------------------------------------------------------
496
497    #[test]
498    fn take_pending_changes_drains_the_buffer_and_the_second_take_is_empty() {
499        let mut m = GpuStateManager::default();
500        m.pending_changes
501            .transform_key_changes
502            .push(GpuTransformKeyEvent::Added(
503                NodeId::new(1),
504                TransformKey::unique(),
505                tx(0.0, 4.0),
506            ));
507
508        let taken = m.take_pending_changes();
509        assert_eq!(taken.transform_key_changes.len(), 1);
510        // The whole point of `take`: the buffer must not replay next frame.
511        assert!(m.pending_changes.is_empty());
512        assert!(m.take_pending_changes().is_empty());
513    }
514
515    #[test]
516    fn take_pending_changes_on_a_fresh_manager_is_an_empty_no_panic() {
517        let mut m = GpuStateManager::default();
518        for _ in 0..3 {
519            assert_eq!(m.take_pending_changes(), GpuEventChanges::empty());
520        }
521    }
522
523    // ------------------------------------------------------------------
524    // get_cache / get_or_create_cache
525    // ------------------------------------------------------------------
526
527    #[test]
528    fn get_cache_on_an_unknown_dom_returns_none_and_does_not_create_it() {
529        let m = GpuStateManager::default();
530        assert!(m.get_cache(dom(0)).is_none());
531        assert!(m.get_cache(dom(usize::MAX)).is_none());
532        assert!(m.caches.is_empty(), "get_cache must not mutate");
533    }
534
535    #[test]
536    fn get_or_create_cache_is_idempotent_and_never_clobbers_existing_state() {
537        let mut m = GpuStateManager::default();
538        let node = NodeId::new(7);
539        let key = TransformKey::unique();
540
541        m.get_or_create_cache(dom(0)).transform_keys.insert(node, key);
542        assert_eq!(m.caches.len(), 1);
543
544        // Second call must hand back the *same* cache, not a fresh default one —
545        // otherwise every frame would mint new GPU keys and leak them.
546        let again = m.get_or_create_cache(dom(0));
547        assert_eq!(again.transform_keys.get(&node), Some(&key));
548        assert_eq!(m.caches.len(), 1);
549        assert!(m.get_cache(dom(0)).is_some());
550    }
551
552    #[test]
553    fn caches_for_distinct_dom_ids_including_usize_max_do_not_alias() {
554        let mut m = GpuStateManager::default();
555        let node = NodeId::new(0);
556
557        m.get_or_create_cache(dom(0))
558            .current_opacity_values
559            .insert(node, 0.25);
560        m.get_or_create_cache(dom(usize::MAX))
561            .current_opacity_values
562            .insert(node, 0.75);
563
564        assert_eq!(m.caches.len(), 2);
565        assert_eq!(
566            m.get_cache(dom(0)).unwrap().current_opacity_values.get(&node),
567            Some(&0.25)
568        );
569        assert_eq!(
570            m.get_cache(dom(usize::MAX))
571                .unwrap()
572                .current_opacity_values
573                .get(&node),
574            Some(&0.75)
575        );
576    }
577
578    // ------------------------------------------------------------------
579    // update_scrollbar_transform_key (private)
580    // ------------------------------------------------------------------
581
582    #[test]
583    fn first_update_emits_added_and_populates_both_the_key_and_value_map() {
584        let mut cache = GpuValueCache::default();
585        let mut changes = GpuEventChanges::empty();
586        let node = NodeId::new(3);
587        let t = tx(0.0, 12.0);
588
589        update_scrollbar_transform_key(
590            &mut cache,
591            &mut changes,
592            node,
593            t,
594            ScrollbarOrientation::Vertical,
595        );
596
597        let key = cache.transform_keys.get(&node).copied().expect("key stored");
598        assert_eq!(cache.current_transform_values.get(&node), Some(&t));
599        assert_eq!(
600            changes.transform_key_changes,
601            vec![GpuTransformKeyEvent::Added(node, key, t)]
602        );
603    }
604
605    #[test]
606    fn re_updating_with_an_identical_transform_emits_nothing() {
607        // The cache exists to suppress redundant GPU traffic: a node that did not
608        // move must produce zero events, forever.
609        let mut cache = GpuValueCache::default();
610        let node = NodeId::new(3);
611        let t = tx(0.0, 12.0);
612
613        let mut first = GpuEventChanges::empty();
614        update_scrollbar_transform_key(
615            &mut cache,
616            &mut first,
617            node,
618            t,
619            ScrollbarOrientation::Vertical,
620        );
621        let key = cache.transform_keys.get(&node).copied().unwrap();
622
623        for _ in 0..10 {
624            let mut again = GpuEventChanges::empty();
625            update_scrollbar_transform_key(
626                &mut cache,
627                &mut again,
628                node,
629                t,
630                ScrollbarOrientation::Vertical,
631            );
632            assert!(again.is_empty(), "unchanged transform must not re-emit");
633        }
634        // ...and the key must be stable across those no-op frames.
635        assert_eq!(cache.transform_keys.get(&node).copied(), Some(key));
636    }
637
638    #[test]
639    fn changing_the_transform_emits_changed_with_old_and_new_and_reuses_the_key() {
640        let mut cache = GpuValueCache::default();
641        let node = NodeId::new(3);
642        let old = tx(0.0, 12.0);
643        let new = tx(0.0, 40.0);
644
645        let mut changes = GpuEventChanges::empty();
646        update_scrollbar_transform_key(
647            &mut cache,
648            &mut changes,
649            node,
650            old,
651            ScrollbarOrientation::Vertical,
652        );
653        let key = cache.transform_keys.get(&node).copied().unwrap();
654
655        let mut changes = GpuEventChanges::empty();
656        update_scrollbar_transform_key(
657            &mut cache,
658            &mut changes,
659            node,
660            new,
661            ScrollbarOrientation::Vertical,
662        );
663
664        assert_eq!(
665            changes.transform_key_changes,
666            vec![GpuTransformKeyEvent::Changed(node, key, old, new)]
667        );
668        // Key is reused (not re-minted) and the stored value advances.
669        assert_eq!(cache.transform_keys.get(&node).copied(), Some(key));
670        assert_eq!(cache.current_transform_values.get(&node), Some(&new));
671    }
672
673    #[test]
674    fn vertical_and_horizontal_keys_for_the_same_node_are_fully_independent() {
675        // Both orientations key off the same NodeId but must land in disjoint
676        // maps — otherwise a node with both scrollbars would have its vertical
677        // thumb overwritten by its horizontal one (SpatialTreeItemKey collision).
678        let mut cache = GpuValueCache::default();
679        let mut changes = GpuEventChanges::empty();
680        let node = NodeId::new(5);
681        let v = tx(0.0, 10.0);
682        let h = tx(20.0, 0.0);
683
684        update_scrollbar_transform_key(
685            &mut cache,
686            &mut changes,
687            node,
688            v,
689            ScrollbarOrientation::Vertical,
690        );
691        update_scrollbar_transform_key(
692            &mut cache,
693            &mut changes,
694            node,
695            h,
696            ScrollbarOrientation::Horizontal,
697        );
698
699        assert_eq!(changes.transform_key_changes.len(), 2);
700        assert_eq!(cache.current_transform_values.get(&node), Some(&v));
701        assert_eq!(cache.h_current_transform_values.get(&node), Some(&h));
702        assert_ne!(
703            cache.transform_keys.get(&node),
704            cache.h_transform_keys.get(&node),
705            "the two orientations must not share a TransformKey"
706        );
707    }
708
709    #[test]
710    fn a_value_without_its_key_silently_drops_the_update_and_stays_stale() {
711        // Desync #1: current_transform_values has an entry but transform_keys does
712        // not. The `let Some(&transform_key) = keys.get(..) else { return }` bails
713        // out *before* writing the new value, so the node is stuck at the stale
714        // transform and never self-heals — no event, no repair, forever.
715        let mut cache = GpuValueCache::default();
716        let node = NodeId::new(3);
717        let stale = tx(0.0, 5.0);
718        cache.current_transform_values.insert(node, stale);
719
720        let mut changes = GpuEventChanges::empty();
721        update_scrollbar_transform_key(
722            &mut cache,
723            &mut changes,
724            node,
725            tx(0.0, 99.0),
726            ScrollbarOrientation::Vertical,
727        );
728
729        assert!(changes.is_empty(), "no event is emitted for a keyless value");
730        assert_eq!(
731            cache.current_transform_values.get(&node),
732            Some(&stale),
733            "the value is left stale rather than repaired"
734        );
735        assert!(
736            !cache.transform_keys.contains_key(&node),
737            "and no key is minted to recover"
738        );
739    }
740
741    #[test]
742    fn a_key_without_its_value_mints_a_fresh_key_and_orphans_the_old_one() {
743        // Desync #2 (the mirror image): transform_keys has an entry but
744        // current_transform_values does not. The else-branch unconditionally
745        // overwrites the key, so the previously-published TransformKey is
746        // orphaned — the renderer still holds it, nothing ever removes it.
747        let mut cache = GpuValueCache::default();
748        let node = NodeId::new(3);
749        let orphan = TransformKey::unique();
750        cache.transform_keys.insert(node, orphan);
751
752        let mut changes = GpuEventChanges::empty();
753        let t = tx(0.0, 7.0);
754        update_scrollbar_transform_key(
755            &mut cache,
756            &mut changes,
757            node,
758            t,
759            ScrollbarOrientation::Vertical,
760        );
761
762        let fresh = cache.transform_keys.get(&node).copied().unwrap();
763        assert_ne!(fresh, orphan, "a brand-new key replaces the orphan");
764        assert_eq!(
765            changes.transform_key_changes,
766            vec![GpuTransformKeyEvent::Added(node, fresh, t)],
767            "and it is announced as Added, never as Removed(orphan)"
768        );
769    }
770
771    #[test]
772    fn a_nan_transform_never_converges_and_re_emits_changed_every_single_call() {
773        // ComputedTransform3D derives PartialEq over f32s, so NaN != NaN. Once a
774        // NaN thumb offset lands in the cache, `*existing != transform` is true on
775        // every subsequent call *even for the bit-identical transform* — the cache
776        // can never converge and the renderer gets an unbounded stream of Changed
777        // events, one per frame, for a node that is not moving.
778        let mut cache = GpuValueCache::default();
779        let node = NodeId::new(3);
780        let nan = tx(0.0, f32::NAN);
781
782        let mut changes = GpuEventChanges::empty();
783        update_scrollbar_transform_key(
784            &mut cache,
785            &mut changes,
786            node,
787            nan,
788            ScrollbarOrientation::Vertical,
789        );
790        assert_eq!(changes.transform_key_changes.len(), 1, "Added");
791
792        // Feed the exact same NaN transform back in 5 more times.
793        for _ in 0..5 {
794            update_scrollbar_transform_key(
795                &mut cache,
796                &mut changes,
797                node,
798                nan,
799                ScrollbarOrientation::Vertical,
800            );
801        }
802        assert_eq!(
803            changes.transform_key_changes.len(),
804            6,
805            "NaN re-emits Changed on every call instead of settling"
806        );
807        assert!(cache.current_transform_values[&node].m[3][1].is_nan());
808    }
809
810    // ------------------------------------------------------------------
811    // remap_hashmap
812    // ------------------------------------------------------------------
813
814    /// `NodeIdMap` in which nothing survived the rebuild.
815    fn no_survivors() -> NodeIdMap {
816        NodeIdMap::from_pairs(Vec::<(NodeId, NodeId)>::new())
817    }
818
819    #[test]
820    fn remap_hashmap_on_empty_inputs_is_a_no_op() {
821        let mut map: HashMap<NodeId, u32> = HashMap::new();
822        remap_hashmap(&mut map, &no_survivors());
823        assert!(map.is_empty());
824    }
825
826    #[test]
827    fn remap_hashmap_drops_entries_for_unmounted_nodes() {
828        let mut map: HashMap<NodeId, u32> = HashMap::new();
829        map.insert(NodeId::new(1), 10);
830        map.insert(NodeId::new(2), 20);
831        map.insert(NodeId::new(3), 30);
832
833        // Only node 2 survives the rebuild (as node 9).
834        remap_hashmap(
835            &mut map,
836            &NodeIdMap::from_pairs([(NodeId::new(2), NodeId::new(9))]),
837        );
838
839        assert_eq!(map.len(), 1);
840        assert_eq!(map.get(&NodeId::new(9)), Some(&20));
841        assert!(!map.contains_key(&NodeId::new(1)));
842        assert!(!map.contains_key(&NodeId::new(2)), "old id must not linger");
843    }
844
845    #[test]
846    fn remap_hashmap_survives_a_full_id_swap_without_losing_entries() {
847        // 1 -> 2 and 2 -> 1 simultaneously. An in-place rewrite would clobber one
848        // of them depending on iteration order; the take-then-reinsert must not.
849        let mut map: HashMap<NodeId, u32> = HashMap::new();
850        map.insert(NodeId::new(1), 111);
851        map.insert(NodeId::new(2), 222);
852
853        remap_hashmap(
854            &mut map,
855            &NodeIdMap::from_pairs([
856                (NodeId::new(1), NodeId::new(2)),
857                (NodeId::new(2), NodeId::new(1)),
858            ]),
859        );
860
861        assert_eq!(map.len(), 2, "no entry may be lost to the swap");
862        assert_eq!(map.get(&NodeId::new(2)), Some(&111));
863        assert_eq!(map.get(&NodeId::new(1)), Some(&222));
864    }
865
866    #[test]
867    fn remap_hashmap_collapses_two_old_ids_that_alias_onto_one_new_id() {
868        // A malformed NodeIdMap (two survivors claiming the same new slot) must
869        // not panic — one entry silently wins. Pinning the *shape* of that loss:
870        // the map shrinks rather than corrupting.
871        let mut map: HashMap<NodeId, u32> = HashMap::new();
872        map.insert(NodeId::new(1), 111);
873        map.insert(NodeId::new(2), 222);
874
875        remap_hashmap(
876            &mut map,
877            &NodeIdMap::from_pairs([
878                (NodeId::new(1), NodeId::new(5)),
879                (NodeId::new(2), NodeId::new(5)),
880            ]),
881        );
882
883        assert_eq!(map.len(), 1, "the alias collapses both entries into one");
884        let survivor = map.get(&NodeId::new(5)).copied().unwrap();
885        assert!(survivor == 111 || survivor == 222);
886    }
887
888    #[test]
889    fn remap_hashmap_handles_node_id_max_without_overflowing() {
890        let mut map: HashMap<NodeId, u32> = HashMap::new();
891        map.insert(NodeId::new(usize::MAX), 1);
892        map.insert(NodeId::ZERO, 2);
893
894        remap_hashmap(
895            &mut map,
896            &NodeIdMap::from_pairs([
897                (NodeId::new(usize::MAX), NodeId::ZERO),
898                (NodeId::ZERO, NodeId::new(usize::MAX)),
899            ]),
900        );
901
902        assert_eq!(map.get(&NodeId::ZERO), Some(&1));
903        assert_eq!(map.get(&NodeId::new(usize::MAX)), Some(&2));
904    }
905
906    // ------------------------------------------------------------------
907    // remap_dom_hashmap
908    // ------------------------------------------------------------------
909
910    #[test]
911    fn remap_dom_hashmap_leaves_other_doms_completely_untouched() {
912        let mut map: HashMap<(DomId, NodeId), u32> = HashMap::new();
913        map.insert((dom(0), NodeId::new(1)), 1);
914        map.insert((dom(1), NodeId::new(1)), 2);
915
916        // Reconcile DOM 0 only: 1 -> 4. DOM 1's node 1 is *not* mentioned in the
917        // map, but it must survive anyway — this reconciliation says nothing
918        // about a different DOM.
919        remap_dom_hashmap(
920            &mut map,
921            dom(0),
922            &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(4))]),
923        );
924
925        assert_eq!(map.len(), 2);
926        assert_eq!(map.get(&(dom(0), NodeId::new(4))), Some(&1));
927        assert_eq!(
928            map.get(&(dom(1), NodeId::new(1))),
929            Some(&2),
930            "a foreign DOM's entry must not be dropped as 'unmounted'"
931        );
932    }
933
934    #[test]
935    fn remap_dom_hashmap_drops_only_the_target_doms_unmounted_nodes() {
936        let mut map: HashMap<(DomId, NodeId), u32> = HashMap::new();
937        map.insert((dom(0), NodeId::new(1)), 1); // survives -> 4
938        map.insert((dom(0), NodeId::new(2)), 2); // unmounted -> dropped
939        map.insert((dom(1), NodeId::new(2)), 3); // other DOM -> kept as-is
940
941        remap_dom_hashmap(
942            &mut map,
943            dom(0),
944            &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(4))]),
945        );
946
947        assert_eq!(map.len(), 2);
948        assert_eq!(map.get(&(dom(0), NodeId::new(4))), Some(&1));
949        assert!(!map.contains_key(&(dom(0), NodeId::new(2))));
950        assert_eq!(map.get(&(dom(1), NodeId::new(2))), Some(&3));
951    }
952
953    #[test]
954    fn remap_dom_hashmap_does_not_let_a_remap_collide_across_doms() {
955        // (dom0, 1) -> (dom0, 2), while (dom1, 2) already exists. Different DOM,
956        // so the tuple keys stay distinct and neither entry is lost.
957        let mut map: HashMap<(DomId, NodeId), u32> = HashMap::new();
958        map.insert((dom(0), NodeId::new(1)), 11);
959        map.insert((dom(1), NodeId::new(2)), 22);
960
961        remap_dom_hashmap(
962            &mut map,
963            dom(0),
964            &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(2))]),
965        );
966
967        assert_eq!(map.len(), 2);
968        assert_eq!(map.get(&(dom(0), NodeId::new(2))), Some(&11));
969        assert_eq!(map.get(&(dom(1), NodeId::new(2))), Some(&22));
970    }
971
972    #[test]
973    fn remap_dom_hashmap_survives_a_shift_chain_within_one_dom() {
974        // 1->2 and 2->3 at once. Resolving from the *old* snapshot means the
975        // 1->2 insert cannot clobber the entry that used to live at 2.
976        let mut map: HashMap<(DomId, NodeId), u32> = HashMap::new();
977        map.insert((dom(0), NodeId::new(1)), 11);
978        map.insert((dom(0), NodeId::new(2)), 22);
979
980        remap_dom_hashmap(
981            &mut map,
982            dom(0),
983            &NodeIdMap::from_pairs([
984                (NodeId::new(1), NodeId::new(2)),
985                (NodeId::new(2), NodeId::new(3)),
986            ]),
987        );
988
989        assert_eq!(map.len(), 2);
990        assert_eq!(map.get(&(dom(0), NodeId::new(2))), Some(&11));
991        assert_eq!(map.get(&(dom(0), NodeId::new(3))), Some(&22));
992    }
993
994    // ------------------------------------------------------------------
995    // NodeIdRemap for GpuStateManager
996    // ------------------------------------------------------------------
997
998    #[test]
999    fn remap_node_ids_for_a_dom_with_no_cache_is_a_no_op() {
1000        let mut m = GpuStateManager::default();
1001        m.remap_node_ids(
1002            dom(3),
1003            &NodeIdMap::from_pairs([(NodeId::new(1), NodeId::new(2))]),
1004        );
1005        assert!(m.caches.is_empty());
1006    }
1007
1008    #[test]
1009    fn remap_node_ids_rewrites_every_one_of_the_twelve_cache_maps() {
1010        // A single map left un-remapped is a stale scrollbar thumb / stuck
1011        // animation, so assert all twelve move together.
1012        let mut m = GpuStateManager::default();
1013        let old = NodeId::new(1);
1014        let new = NodeId::new(8);
1015        let d = dom(0);
1016        {
1017            let c = m.get_or_create_cache(d);
1018            c.transform_keys.insert(old, TransformKey::unique());
1019            c.current_transform_values.insert(old, tx(0.0, 1.0));
1020            c.h_transform_keys.insert(old, TransformKey::unique());
1021            c.h_current_transform_values.insert(old, tx(2.0, 0.0));
1022            c.css_transform_keys.insert(old, TransformKey::unique());
1023            c.css_current_transform_values.insert(old, tx(3.0, 3.0));
1024            c.opacity_keys.insert(old, OpacityKey::unique());
1025            c.current_opacity_values.insert(old, 0.5);
1026            c.scrollbar_v_opacity_keys.insert((d, old), OpacityKey::unique());
1027            c.scrollbar_h_opacity_keys.insert((d, old), OpacityKey::unique());
1028            c.scrollbar_v_opacity_values.insert((d, old), 0.25);
1029            c.scrollbar_h_opacity_values.insert((d, old), 0.75);
1030        }
1031
1032        m.remap_node_ids(d, &NodeIdMap::from_pairs([(old, new)]));
1033
1034        let c = m.get_cache(d).unwrap();
1035        assert!(c.transform_keys.contains_key(&new));
1036        assert_eq!(c.current_transform_values.get(&new), Some(&tx(0.0, 1.0)));
1037        assert!(c.h_transform_keys.contains_key(&new));
1038        assert_eq!(c.h_current_transform_values.get(&new), Some(&tx(2.0, 0.0)));
1039        assert!(c.css_transform_keys.contains_key(&new));
1040        assert_eq!(c.css_current_transform_values.get(&new), Some(&tx(3.0, 3.0)));
1041        assert!(c.opacity_keys.contains_key(&new));
1042        assert_eq!(c.current_opacity_values.get(&new), Some(&0.5));
1043        assert!(c.scrollbar_v_opacity_keys.contains_key(&(d, new)));
1044        assert!(c.scrollbar_h_opacity_keys.contains_key(&(d, new)));
1045        assert_eq!(c.scrollbar_v_opacity_values.get(&(d, new)), Some(&0.25));
1046        assert_eq!(c.scrollbar_h_opacity_values.get(&(d, new)), Some(&0.75));
1047
1048        // ...and nothing is left behind under the old id.
1049        assert!(!c.transform_keys.contains_key(&old));
1050        assert!(!c.current_opacity_values.contains_key(&old));
1051        assert!(!c.scrollbar_v_opacity_values.contains_key(&(d, old)));
1052    }
1053
1054    #[test]
1055    fn remap_node_ids_drops_the_gpu_keys_of_an_unmounted_node() {
1056        let mut m = GpuStateManager::default();
1057        let gone = NodeId::new(1);
1058        let d = dom(0);
1059        {
1060            let c = m.get_or_create_cache(d);
1061            c.transform_keys.insert(gone, TransformKey::unique());
1062            c.current_transform_values.insert(gone, tx(0.0, 1.0));
1063            c.scrollbar_v_opacity_values.insert((d, gone), 1.0);
1064        }
1065
1066        // Empty map == every node unmounted.
1067        m.remap_node_ids(d, &no_survivors());
1068
1069        let c = m.get_cache(d).unwrap();
1070        assert!(c.transform_keys.is_empty());
1071        assert!(c.current_transform_values.is_empty());
1072        assert!(c.scrollbar_v_opacity_values.is_empty());
1073    }
1074
1075    // ------------------------------------------------------------------
1076    // update_scrollbar_transforms
1077    // ------------------------------------------------------------------
1078
1079    #[test]
1080    fn update_scrollbar_transforms_on_an_empty_tree_yields_no_events() {
1081        let mut m = GpuStateManager::default();
1082        let sm = ScrollManager::new();
1083        let t = tree(Vec::new(), Vec::new());
1084
1085        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
1086        assert!(changes.is_empty());
1087        // ...but the cache is created (get_or_create_cache runs unconditionally).
1088        assert!(m.get_cache(dom(0)).is_some());
1089    }
1090
1091    #[test]
1092    fn nodes_without_scrollbar_info_or_without_a_dom_node_id_are_skipped() {
1093        let mut m = GpuStateManager::default();
1094        let sm = ScrollManager::new();
1095        let t = tree(
1096            vec![
1097                // has scrollbar info but is an anonymous box (no dom_node_id)
1098                hot(None, Some(LogicalSize::new(100.0, 100.0))),
1099                // has a dom_node_id but no scrollbar info
1100                hot(Some(NodeId::new(2)), Some(LogicalSize::new(100.0, 100.0))),
1101            ],
1102            vec![
1103                warm_node(Some(v_scrollbar()), Some(LogicalSize::new(100.0, 1000.0))),
1104                warm_node(None, None),
1105            ],
1106        );
1107
1108        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
1109        assert!(changes.is_empty());
1110        assert!(m.get_cache(dom(0)).unwrap().transform_keys.is_empty());
1111    }
1112
1113    #[test]
1114    fn a_warm_array_shorter_than_the_node_array_does_not_index_out_of_bounds() {
1115        // Mismatched SoA lengths: `warm(idx)` returns None for the tail nodes and
1116        // they must be skipped, not panic.
1117        let mut m = GpuStateManager::default();
1118        let sm = ScrollManager::new();
1119        let t = tree(
1120            vec![
1121                hot(Some(NodeId::new(1)), Some(LogicalSize::new(100.0, 100.0))),
1122                hot(Some(NodeId::new(2)), Some(LogicalSize::new(100.0, 100.0))),
1123            ],
1124            // only one warm entry for two hot nodes
1125            vec![warm_node(Some(v_scrollbar()), Some(LogicalSize::new(100.0, 1000.0)))],
1126        );
1127
1128        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
1129        assert_eq!(changes.transform_key_changes.len(), 1, "only node 0 is seen");
1130    }
1131
1132    #[test]
1133    fn a_vertical_scrollbar_at_scroll_zero_parks_the_thumb_at_offset_zero() {
1134        let mut m = GpuStateManager::default();
1135        let sm = ScrollManager::new();
1136        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
1137
1138        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
1139        // No scroll state registered at all -> get_current_offset() is None ->
1140        // unwrap_or_default() -> (0, 0). Thumb sits at the top.
1141        assert_eq!(sole_added_y(&changes), 0.0);
1142    }
1143
1144    #[test]
1145    fn scrolling_to_the_bottom_drives_the_thumb_to_the_end_of_the_usable_track() {
1146        // inner 100x100, content 100x1000, 16px bar with 16px buttons:
1147        //   usable_track = 100 - 2*16 = 68
1148        //   thumb        = max(68 * (100/1000), 16*2) = 32
1149        //   max_scroll   = 1000 - 100 = 900
1150        // At full scroll the thumb must land exactly at 68 - 32 = 36, never past it.
1151        let mut m = GpuStateManager::default();
1152        let mut sm = ScrollManager::new();
1153        sm.set_scroll_position_unclamped(
1154            dom(0),
1155            NodeId::new(1),
1156            LogicalPosition::new(0.0, 900.0),
1157            t0(),
1158        );
1159        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
1160
1161        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
1162        assert!((y - 36.0).abs() < 0.01, "expected thumb at 36.0, got {y}");
1163    }
1164
1165    #[test]
1166    fn an_overscrolled_offset_clamps_the_thumb_instead_of_running_off_the_track() {
1167        // Rubber-banding pushes the offset far past max_scroll; scroll_ratio is
1168        // clamped to [0, 1] so the thumb must stop at the same 36.0.
1169        let mut m = GpuStateManager::default();
1170        let mut sm = ScrollManager::new();
1171        sm.set_scroll_position_unclamped(
1172            dom(0),
1173            NodeId::new(1),
1174            LogicalPosition::new(0.0, 1.0e9),
1175            t0(),
1176        );
1177        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
1178
1179        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
1180        assert!((y - 36.0).abs() < 0.01, "overscroll must clamp, got {y}");
1181    }
1182
1183    #[test]
1184    fn a_negative_scroll_offset_is_treated_as_its_absolute_value() {
1185        // compute_thumb_geometry uses scroll_offset.abs(), so an overscroll *above*
1186        // the top does not produce a negative thumb offset.
1187        let mut m = GpuStateManager::default();
1188        let mut sm = ScrollManager::new();
1189        sm.set_scroll_position_unclamped(
1190            dom(0),
1191            NodeId::new(1),
1192            LogicalPosition::new(0.0, -900.0),
1193            t0(),
1194        );
1195        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
1196
1197        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
1198        assert!(y >= 0.0, "thumb offset must never go negative, got {y}");
1199        assert!((y - 36.0).abs() < 0.01);
1200    }
1201
1202    #[test]
1203    fn running_the_same_layout_twice_emits_no_second_event() {
1204        // The convergence invariant: a static scroll position must not generate
1205        // GPU traffic every frame.
1206        let mut m = GpuStateManager::default();
1207        let sm = ScrollManager::new();
1208        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
1209
1210        let first = m.update_scrollbar_transforms(dom(0), &sm, &t);
1211        assert_eq!(first.transform_key_changes.len(), 1);
1212
1213        for _ in 0..5 {
1214            let again = m.update_scrollbar_transforms(dom(0), &sm, &t);
1215            assert!(again.is_empty(), "an idle scrollbar must stay silent");
1216        }
1217    }
1218
1219    #[test]
1220    fn scrolling_after_a_first_pass_emits_changed_and_reuses_the_key() {
1221        let mut m = GpuStateManager::default();
1222        let mut sm = ScrollManager::new();
1223        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
1224
1225        m.update_scrollbar_transforms(dom(0), &sm, &t);
1226        let key = m
1227            .get_cache(dom(0))
1228            .unwrap()
1229            .transform_keys
1230            .get(&NodeId::new(1))
1231            .copied()
1232            .unwrap();
1233
1234        sm.set_scroll_position_unclamped(
1235            dom(0),
1236            NodeId::new(1),
1237            LogicalPosition::new(0.0, 900.0),
1238            t0(),
1239        );
1240        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
1241
1242        assert_eq!(changes.transform_key_changes.len(), 1);
1243        match changes.transform_key_changes[0] {
1244            GpuTransformKeyEvent::Changed(node, k, old, new) => {
1245                assert_eq!(node, NodeId::new(1));
1246                assert_eq!(k, key, "the key must be reused across the scroll");
1247                assert_eq!(old.m[3][1], 0.0);
1248                assert!((new.m[3][1] - 36.0).abs() < 0.01);
1249            }
1250            ref other => panic!("expected Changed, got {other:?}"),
1251        }
1252    }
1253
1254    #[test]
1255    fn a_horizontal_scrollbar_translates_on_x_and_leaves_y_at_zero() {
1256        let mut m = GpuStateManager::default();
1257        let mut sm = ScrollManager::new();
1258        sm.set_scroll_position_unclamped(
1259            dom(0),
1260            NodeId::new(1),
1261            LogicalPosition::new(900.0, 0.0),
1262            t0(),
1263        );
1264        let t = one_node_tree(h_scrollbar(), LogicalSize::new(1000.0, 100.0));
1265
1266        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
1267        let x = sole_added_x(&changes);
1268        assert!((x - 36.0).abs() < 0.01, "expected thumb at x=36.0, got {x}");
1269
1270        // The horizontal thumb must be filed under the h_* maps, not the v_* ones.
1271        let c = m.get_cache(dom(0)).unwrap();
1272        assert!(c.h_transform_keys.contains_key(&NodeId::new(1)));
1273        assert!(c.transform_keys.is_empty());
1274    }
1275
1276    #[test]
1277    fn a_node_needing_both_scrollbars_gets_two_independent_keys_and_two_events() {
1278        let mut m = GpuStateManager::default();
1279        let sm = ScrollManager::new();
1280        let both = ScrollbarRequirements {
1281            needs_horizontal: true,
1282            needs_vertical: true,
1283            scrollbar_width: 16.0,
1284            scrollbar_height: 16.0,
1285            visual_width_px: 16.0,
1286        };
1287        let t = one_node_tree(both, LogicalSize::new(1000.0, 1000.0));
1288
1289        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
1290        assert_eq!(changes.transform_key_changes.len(), 2);
1291
1292        let c = m.get_cache(dom(0)).unwrap();
1293        let node = NodeId::new(1);
1294        let v = c.transform_keys.get(&node).copied().unwrap();
1295        let h = c.h_transform_keys.get(&node).copied().unwrap();
1296        assert_ne!(v, h);
1297    }
1298
1299    #[test]
1300    fn borders_wider_than_the_border_box_clamp_the_inner_size_to_zero() {
1301        // 50x50 border-box with a 100px border on every side would give a -150px
1302        // inner size; the `.max(0.0)` must clamp it, and the geometry must stay
1303        // finite (no NaN thumb offset from a negative track).
1304        let mut m = GpuStateManager::default();
1305        let sm = ScrollManager::new();
1306        let mut node = hot(Some(NodeId::new(1)), Some(LogicalSize::new(50.0, 50.0)));
1307        node.box_props = PackedBoxProps {
1308            border: [1000, 1000, 1000, 1000], // 100.0 px each, i16 x10 encoding
1309            ..Default::default()
1310        };
1311        let t = tree(
1312            vec![node],
1313            vec![warm_node(
1314                Some(v_scrollbar()),
1315                Some(LogicalSize::new(100.0, 1000.0)),
1316            )],
1317        );
1318
1319        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
1320        assert!(y.is_finite(), "a degenerate inner box must not yield {y}");
1321        assert_eq!(y, 0.0);
1322    }
1323
1324    #[test]
1325    fn a_zero_sized_node_with_zero_content_does_not_divide_by_zero() {
1326        let mut m = GpuStateManager::default();
1327        let sm = ScrollManager::new();
1328        let t = tree(
1329            vec![hot(Some(NodeId::new(1)), Some(LogicalSize::zero()))],
1330            vec![warm_node(Some(v_scrollbar()), Some(LogicalSize::zero()))],
1331        );
1332
1333        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
1334        assert!(y.is_finite());
1335        assert_eq!(y, 0.0);
1336    }
1337
1338    #[test]
1339    fn a_missing_used_size_defaults_to_zero_rather_than_panicking() {
1340        let mut m = GpuStateManager::default();
1341        let sm = ScrollManager::new();
1342        let t = tree(
1343            vec![hot(Some(NodeId::new(1)), None)],
1344            vec![warm_node(Some(v_scrollbar()), None)],
1345        );
1346
1347        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
1348        assert!(y.is_finite());
1349        assert_eq!(y, 0.0);
1350    }
1351
1352    #[test]
1353    fn an_infinite_used_size_produces_a_nan_thumb_offset() {
1354        // BUG (characterisation): an infinite border-box makes both the viewport
1355        // and the content length +inf, so compute_thumb_geometry ends at
1356        //   thumb_offset = (inf - inf) * 0.0 = NaN
1357        // and gpu_state feeds that NaN straight into a translation matrix with no
1358        // finite-check. Combined with `a_nan_transform_never_converges_...` above,
1359        // one infinite used_size means the scrollbar re-emits a Changed event on
1360        // *every* frame, forever, and WebRender is handed a NaN transform.
1361        // Asserting the current behaviour so a future finite-guard trips this test.
1362        let mut m = GpuStateManager::default();
1363        let sm = ScrollManager::new();
1364        let t = tree(
1365            vec![hot(
1366                Some(NodeId::new(1)),
1367                Some(LogicalSize::new(f32::INFINITY, f32::INFINITY)),
1368            )],
1369            vec![warm_node(Some(v_scrollbar()), None)],
1370        );
1371
1372        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
1373        assert!(y.is_nan(), "expected the documented NaN, got {y}");
1374
1375        // And it never settles: a second identical pass re-emits Changed.
1376        let again = m.update_scrollbar_transforms(dom(0), &sm, &t);
1377        assert_eq!(
1378            again.transform_key_changes.len(),
1379            1,
1380            "NaN keeps the cache from ever converging"
1381        );
1382    }
1383
1384    #[test]
1385    fn a_nan_used_size_is_sanitised_to_zero_by_the_max_clamp() {
1386        // Unlike infinity, NaN *is* neutralised: f32::max(NaN, 0.0) == 0.0, so the
1387        // `.max(0.0)` on the inner size scrubs it before it reaches the geometry.
1388        let mut m = GpuStateManager::default();
1389        let sm = ScrollManager::new();
1390        let t = tree(
1391            vec![hot(
1392                Some(NodeId::new(1)),
1393                Some(LogicalSize::new(f32::NAN, f32::NAN)),
1394            )],
1395            vec![warm_node(Some(v_scrollbar()), None)],
1396        );
1397
1398        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
1399        assert!(y.is_finite(), "NaN width/height must be clamped, got {y}");
1400        assert_eq!(y, 0.0);
1401    }
1402
1403    #[test]
1404    fn overlay_vertical_scrollbars_fall_back_to_the_default_width() {
1405        // visual_width_px == 0 and no reserved space at all -> the DEFAULT_SCROLLBAR
1406        // _WIDTH_PX (16.0) fallback with button_size 0:
1407        //   usable = 100, thumb = max(100*0.1, 32) = 32, offset@full = 100 - 32 = 68
1408        let mut m = GpuStateManager::default();
1409        let mut sm = ScrollManager::new();
1410        sm.set_scroll_position_unclamped(
1411            dom(0),
1412            NodeId::new(1),
1413            LogicalPosition::new(0.0, 900.0),
1414            t0(),
1415        );
1416        let overlay = ScrollbarRequirements {
1417            needs_horizontal: false,
1418            needs_vertical: true,
1419            scrollbar_width: 0.0,
1420            scrollbar_height: 0.0,
1421            visual_width_px: 0.0,
1422        };
1423        let t = one_node_tree(overlay, LogicalSize::new(100.0, 1000.0));
1424
1425        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
1426        assert!((y - 68.0).abs() < 0.01, "expected the 16px default, got {y}");
1427        assert_eq!(DEFAULT_SCROLLBAR_WIDTH_PX, 16.0);
1428    }
1429
1430    #[test]
1431    fn classic_vertical_scrollbar_overlay_check_reads_the_wrong_reserved_field() {
1432        // BUG (characterisation). ScrollbarRequirements documents:
1433        //   scrollbar_width  = layout-reserved width  for a *vertical*   scrollbar
1434        //   scrollbar_height = layout-reserved height for a *horizontal* scrollbar
1435        // but the needs_vertical branch tests `scrollbar_height == 0.0` to decide
1436        // whether the *vertical* bar is an overlay, and falls back to
1437        // `scrollbar_height` for its width. The two fields are swapped.
1438        //
1439        // Repro: a classic, space-reserving vertical-only scrollbar --
1440        //   scrollbar_width  = 16.0  (16px reserved for the vertical bar)
1441        //   scrollbar_height =  0.0  (no horizontal bar -> nothing reserved)
1442        //   visual_width_px  =  0.0  (unset, so the fallback actually runs)
1443        // is misread as an overlay: button_size collapses to 0, so the usable
1444        // track is 100 instead of 68 and the thumb travels to 68.0 rather than the
1445        // correct 36.0 -- the thumb overshoots its own track by ~32px.
1446        let mut m = GpuStateManager::default();
1447        let mut sm = ScrollManager::new();
1448        sm.set_scroll_position_unclamped(
1449            dom(0),
1450            NodeId::new(1),
1451            LogicalPosition::new(0.0, 900.0),
1452            t0(),
1453        );
1454        let classic_vertical_only = ScrollbarRequirements {
1455            needs_horizontal: false,
1456            needs_vertical: true,
1457            scrollbar_width: 16.0,
1458            scrollbar_height: 0.0,
1459            visual_width_px: 0.0,
1460        };
1461        let t = one_node_tree(classic_vertical_only, LogicalSize::new(100.0, 1000.0));
1462
1463        let y = sole_added_y(&m.update_scrollbar_transforms(dom(0), &sm, &t));
1464        assert!(
1465            (y - 68.0).abs() < 0.01,
1466            "pinning the buggy value; 36.0 once the field swap is fixed, got {y}"
1467        );
1468    }
1469
1470    #[test]
1471    fn classic_horizontal_scrollbar_overlay_check_reads_the_wrong_reserved_field() {
1472        // The mirror image of the above: the needs_horizontal branch tests
1473        // `scrollbar_width == 0.0` (the *vertical* bar's reserved width) to decide
1474        // whether the *horizontal* bar is an overlay.
1475        let mut m = GpuStateManager::default();
1476        let mut sm = ScrollManager::new();
1477        sm.set_scroll_position_unclamped(
1478            dom(0),
1479            NodeId::new(1),
1480            LogicalPosition::new(900.0, 0.0),
1481            t0(),
1482        );
1483        let classic_horizontal_only = ScrollbarRequirements {
1484            needs_horizontal: true,
1485            needs_vertical: false,
1486            scrollbar_width: 0.0,
1487            scrollbar_height: 16.0,
1488            visual_width_px: 0.0,
1489        };
1490        let t = one_node_tree(classic_horizontal_only, LogicalSize::new(1000.0, 100.0));
1491
1492        let x = sole_added_x(&m.update_scrollbar_transforms(dom(0), &sm, &t));
1493        assert!(
1494            (x - 68.0).abs() < 0.01,
1495            "pinning the buggy value; 36.0 once the field swap is fixed, got {x}"
1496        );
1497    }
1498
1499    #[test]
1500    fn transforms_are_recorded_per_dom_and_do_not_leak_across_caches() {
1501        let mut m = GpuStateManager::default();
1502        let sm = ScrollManager::new();
1503        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
1504
1505        let a = m.update_scrollbar_transforms(dom(0), &sm, &t);
1506        let b = m.update_scrollbar_transforms(dom(1), &sm, &t);
1507
1508        // The same layout tree under a different DomId is a *different* cache, so
1509        // it must Add (not stay silent) and mint its own key.
1510        assert_eq!(a.transform_key_changes.len(), 1);
1511        assert_eq!(b.transform_key_changes.len(), 1);
1512        assert_eq!(m.caches.len(), 2);
1513
1514        let node = NodeId::new(1);
1515        let ka = m.get_cache(dom(0)).unwrap().transform_keys[&node];
1516        let kb = m.get_cache(dom(1)).unwrap().transform_keys[&node];
1517        assert_ne!(ka, kb, "each DOM must get its own TransformKey");
1518    }
1519
1520    #[test]
1521    fn update_scrollbar_transforms_does_not_touch_pending_changes() {
1522        // The function *returns* its changes; it must not also stash them, or the
1523        // renderer would apply every event twice.
1524        let mut m = GpuStateManager::default();
1525        let sm = ScrollManager::new();
1526        let t = one_node_tree(v_scrollbar(), LogicalSize::new(100.0, 1000.0));
1527
1528        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
1529        assert!(!changes.is_empty());
1530        assert!(
1531            m.pending_changes.is_empty(),
1532            "returned events must not be double-queued"
1533        );
1534    }
1535
1536    #[test]
1537    fn a_thousand_scrollable_nodes_each_get_exactly_one_distinct_key() {
1538        let mut m = GpuStateManager::default();
1539        let sm = ScrollManager::new();
1540        let n = 1000;
1541        let t = tree(
1542            (0..n)
1543                .map(|i| hot(Some(NodeId::new(i)), Some(LogicalSize::new(100.0, 100.0))))
1544                .collect(),
1545            (0..n)
1546                .map(|_| warm_node(Some(v_scrollbar()), Some(LogicalSize::new(100.0, 1000.0))))
1547                .collect(),
1548        );
1549
1550        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
1551        assert_eq!(changes.transform_key_changes.len(), n);
1552
1553        let cache = m.get_cache(dom(0)).unwrap();
1554        assert_eq!(cache.transform_keys.len(), n);
1555        let mut keys: Vec<_> = cache.transform_keys.values().map(|k| k.id).collect();
1556        keys.sort_unstable();
1557        keys.dedup();
1558        assert_eq!(keys.len(), n, "every node must get a unique TransformKey");
1559    }
1560
1561    #[test]
1562    fn two_layout_nodes_sharing_one_dom_node_id_fight_over_a_single_key() {
1563        // Anonymous-box splitting can produce two layout nodes pointing at the same
1564        // DOM node. The cache keys off dom_node_id, not the layout index, so the
1565        // second node is misread as a *change* to the first: one key, two events
1566        // in a single pass, and the last node in tree order silently wins.
1567        //
1568        //   node 0: inner 100x100, content 100x1000 -> thumb 36.0
1569        //   node 1: inner 200x200, content 200x2000 -> thumb 68.0
1570        let mut m = GpuStateManager::default();
1571        let mut sm = ScrollManager::new();
1572        sm.set_scroll_position_unclamped(
1573            dom(0),
1574            NodeId::new(1),
1575            LogicalPosition::new(0.0, 900.0),
1576            t0(),
1577        );
1578        let t = tree(
1579            vec![
1580                hot(Some(NodeId::new(1)), Some(LogicalSize::new(100.0, 100.0))),
1581                hot(Some(NodeId::new(1)), Some(LogicalSize::new(200.0, 200.0))),
1582            ],
1583            vec![
1584                warm_node(Some(v_scrollbar()), Some(LogicalSize::new(100.0, 1000.0))),
1585                warm_node(Some(v_scrollbar()), Some(LogicalSize::new(200.0, 2000.0))),
1586            ],
1587        );
1588
1589        let changes = m.update_scrollbar_transforms(dom(0), &sm, &t);
1590        assert_eq!(
1591            m.get_cache(dom(0)).unwrap().transform_keys.len(),
1592            1,
1593            "both layout nodes collapse onto one TransformKey"
1594        );
1595        assert!(matches!(
1596            changes.transform_key_changes.as_slice(),
1597            [
1598                GpuTransformKeyEvent::Added(..),
1599                GpuTransformKeyEvent::Changed(..)
1600            ]
1601        ));
1602        // The second node overwrote the first within the same pass.
1603        let stored = m.get_cache(dom(0)).unwrap().current_transform_values[&NodeId::new(1)];
1604        assert!((stored.m[3][1] - 68.0).abs() < 0.01);
1605    }
1606}