Skip to main content

fission_shell_winit/
pipeline.rs

1use crate::web_backend::WebSurfaceFrame;
2use anyhow::Result;
3use fission_core::diff::diff_ir;
4use fission_core::env::{AnimationStateMap, Env, VideoStateMap, WebStateMap};
5use fission_core::internal::build_layout_tree;
6use fission_core::internal::downcast_render_object;
7use fission_core::registry::AnimationPropertyId;
8use fission_core::scrollbar::scrollbar_geometry_for_node;
9use fission_core::{LayoutPoint, ScrollStateMap};
10use fission_diagnostics::prelude as diag;
11use fission_diagnostics::{SnapshotBlob, SnapshotKind, SnapshotProvider};
12use fission_ir::{CompositeScalar, CoreIR, EmbedKind, FlexDirection, LayoutOp, Op, WidgetId};
13use fission_layout::{LayoutEngine, LayoutInputNode, LayoutRect, LayoutSize, LayoutSnapshot};
14use fission_render::{
15    embed_surface_id, BoxShadow, Color as RenderColor, DisplayList, DisplayOp, Fill, LayerClip,
16    RenderLayer, RenderNode, RenderScene, Renderer, Stroke,
17};
18use fission_shell::VideoSurfaceFrame;
19use serde::Serialize;
20use std::collections::{HashMap, HashSet};
21use std::hash::{Hash, Hasher};
22#[cfg(not(target_arch = "wasm32"))]
23use std::time::Instant;
24#[cfg(target_arch = "wasm32")]
25use web_time::Instant;
26
27fn render_trace_enabled() -> bool {
28    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
29    *ENABLED.get_or_init(|| std::env::var("FISSION_RENDER_TRACE").is_ok())
30}
31
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33pub struct InvalidationSet {
34    pub build: bool,
35    pub layout: bool,
36    pub paint: bool,
37    pub composite: bool,
38}
39
40impl InvalidationSet {
41    pub fn mark_build(&mut self) {
42        self.build = true;
43        self.layout = true;
44        self.paint = true;
45        self.composite = true;
46    }
47
48    pub fn mark_layout(&mut self) {
49        self.layout = true;
50        self.paint = true;
51        self.composite = true;
52    }
53
54    pub fn mark_paint(&mut self) {
55        self.paint = true;
56        self.composite = true;
57    }
58
59    pub fn mark_composite(&mut self) {
60        self.composite = true;
61    }
62
63    pub fn merge(&mut self, other: Self) {
64        self.build |= other.build;
65        self.layout |= other.layout;
66        self.paint |= other.paint;
67        self.composite |= other.composite;
68    }
69
70    pub fn any(self) -> bool {
71        self.build || self.layout || self.paint || self.composite
72    }
73
74    pub fn highest_class(self) -> &'static str {
75        if self.build {
76            "build"
77        } else if self.layout {
78            "layout"
79        } else if self.paint {
80            "paint"
81        } else if self.composite {
82            "composite"
83        } else {
84            "none"
85        }
86    }
87
88    pub fn labels(self) -> Vec<&'static str> {
89        let mut labels = Vec::new();
90        if self.build {
91            labels.push("build");
92        }
93        if self.layout {
94            labels.push("layout");
95        }
96        if self.paint {
97            labels.push("paint");
98        }
99        if self.composite {
100            labels.push("composite");
101        }
102        if labels.is_empty() {
103            labels.push("none");
104        }
105        labels
106    }
107}
108
109#[derive(Debug, Clone)]
110struct BoundaryCacheEntry {
111    hash: u64,
112    layer: RenderLayer,
113}
114
115#[derive(Debug, Clone)]
116struct OpacityBinding {
117    layer_path: Vec<usize>,
118    scalar: CompositeScalar,
119}
120
121#[derive(Debug, Clone)]
122struct TransformBinding {
123    layer_path: Vec<usize>,
124    rect: LayoutRect,
125    layout_transform: Option<[f32; 16]>,
126    scroll: Option<ScrollTransform>,
127    translate_x: Option<CompositeScalar>,
128    translate_y: Option<CompositeScalar>,
129    scale: Option<CompositeScalar>,
130    rotation: Option<CompositeScalar>,
131}
132
133#[derive(Debug, Clone)]
134struct ScrollbarBinding {
135    node_path: Vec<usize>,
136    node_id: WidgetId,
137}
138
139#[derive(Debug, Clone)]
140struct ScrollTransform {
141    node_id: WidgetId,
142    direction: FlexDirection,
143}
144
145#[derive(Debug, Clone, Default)]
146struct RetainedDynamicOps {
147    opacity: Vec<OpacityBinding>,
148    transform: Vec<TransformBinding>,
149    scrollbar: Vec<ScrollbarBinding>,
150}
151
152#[derive(Debug, Clone)]
153pub struct CompositorTexturePlan {
154    pub key: u64,
155    pub bounds: LayoutRect,
156    pub scene: Option<RenderScene>,
157    pub scene_cache_key: Option<u64>,
158    pub content_key: u64,
159    pub local_dynamic: bool,
160    pub composite_dynamic: bool,
161    pub opacity: f32,
162    pub transform: Option<[f32; 16]>,
163    pub transform_clip: bool,
164    pub clip: Option<LayerClip>,
165    pub children: Vec<CompositorTexturePlan>,
166    pub source_layer_path: Option<Vec<usize>>,
167}
168
169pub struct Pipeline {
170    pub prev_ir: Option<CoreIR>,
171    pub last_snapshot: Option<LayoutSnapshot>,
172    pub paint_cache: HashMap<WidgetId, (u64, DisplayList)>,
173    boundary_cache: HashMap<WidgetId, BoundaryCacheEntry>,
174    pub last_scroll_offsets: HashMap<WidgetId, u32>,
175    pub video_surfaces: Vec<VideoSurfaceFrame>,
176    pub web_surfaces: Vec<WebSurfaceFrame>,
177    pub scene_3d_surfaces: Vec<(WidgetId, LayoutRect, Vec<u8>)>,
178    pub last_viewport: Option<LayoutRect>,
179    pub layout_invariant_violation_count: u32,
180    pub layout_full_rebuild_count: u32,
181    retained_scene: Option<RenderScene>,
182    retained_dynamic_ops: RetainedDynamicOps,
183    layout_input_nodes: Vec<LayoutInputNode>,
184    pending_layout_dirty_nodes: HashSet<WidgetId>,
185    pending_layout_invalidated: bool,
186    pending_layout_full: bool,
187    compositor_animation_keys: HashSet<(WidgetId, AnimationPropertyId)>,
188    runtime_dynamic_nodes: HashSet<WidgetId>,
189    scroll_nodes: HashSet<WidgetId>,
190    runtime_dynamic_subtrees: HashMap<WidgetId, bool>,
191    retained_texture_plans: Vec<CompositorTexturePlan>,
192    retained_texture_root_transform: Option<[f32; 16]>,
193}
194
195pub struct PipelineStats {
196    pub dirty_nodes: usize,
197    pub layout_updates: usize,
198    pub paint_misses: usize,
199    pub paint_hits: usize,
200    pub video_surfaces: usize,
201}
202
203impl Pipeline {
204    pub fn new() -> Self {
205        Self {
206            prev_ir: None,
207            last_snapshot: None,
208            paint_cache: HashMap::new(),
209            boundary_cache: HashMap::new(),
210            last_scroll_offsets: HashMap::new(),
211            video_surfaces: Vec::new(),
212            web_surfaces: Vec::new(),
213            scene_3d_surfaces: Vec::new(),
214            last_viewport: None,
215            layout_invariant_violation_count: 0,
216            layout_full_rebuild_count: 0,
217            retained_scene: None,
218            retained_dynamic_ops: RetainedDynamicOps::default(),
219            layout_input_nodes: Vec::new(),
220            pending_layout_dirty_nodes: HashSet::new(),
221            pending_layout_invalidated: false,
222            pending_layout_full: true,
223            compositor_animation_keys: HashSet::new(),
224            runtime_dynamic_nodes: HashSet::new(),
225            scroll_nodes: HashSet::new(),
226            runtime_dynamic_subtrees: HashMap::new(),
227            retained_texture_plans: Vec::new(),
228            retained_texture_root_transform: None,
229        }
230    }
231
232    pub fn take_video_surfaces(&mut self) -> Vec<VideoSurfaceFrame> {
233        std::mem::take(&mut self.video_surfaces)
234    }
235
236    pub fn take_web_surfaces(&mut self) -> Vec<WebSurfaceFrame> {
237        std::mem::take(&mut self.web_surfaces)
238    }
239
240    pub fn invalidate_layout_all(&mut self) {
241        self.pending_layout_full = true;
242        self.pending_layout_dirty_nodes.clear();
243    }
244
245    pub fn replace_ir(&mut self, next_ir: CoreIR, env: &Env) -> InvalidationSet {
246        let mut invalidation = InvalidationSet::default();
247        let mut rebuild_layout_tree = self.prev_ir.is_none();
248
249        if let Some(prev_ir) = &self.prev_ir {
250            let diff = diff_ir(prev_ir, &next_ir);
251            if !diff.dirty_layout.is_empty() {
252                invalidation.mark_layout();
253                self.pending_layout_invalidated = true;
254                self.pending_layout_dirty_nodes.extend(diff.dirty_layout);
255            }
256            if !diff.dirty_paint.is_empty() {
257                invalidation.mark_paint();
258            }
259            if !diff.dirty_composite.is_empty() {
260                invalidation.mark_composite();
261            }
262            rebuild_layout_tree = rebuild_layout_tree || invalidation.layout;
263        } else {
264            invalidation.mark_build();
265            self.pending_layout_full = true;
266            self.pending_layout_dirty_nodes.clear();
267        }
268
269        if rebuild_layout_tree {
270            self.layout_input_nodes = build_layout_tree(&next_ir, env);
271        }
272
273        if invalidation.layout {
274            self.pending_layout_full |= self.prev_ir.is_none();
275            self.clear_render_caches();
276        } else if invalidation.paint || invalidation.composite {
277            self.clear_render_caches();
278        }
279
280        self.prev_ir = Some(next_ir);
281        self.refresh_retained_metadata();
282        invalidation
283    }
284
285    pub fn classify_animation_updates(
286        &self,
287        changed: &[(WidgetId, AnimationPropertyId)],
288    ) -> InvalidationSet {
289        let mut invalidation = InvalidationSet::default();
290        for key in changed {
291            if self.compositor_animation_keys.contains(key) {
292                invalidation.mark_composite();
293            } else {
294                invalidation.mark_build();
295            }
296        }
297        invalidation
298    }
299
300    pub fn ensure_layout(
301        &mut self,
302        viewport: LayoutRect,
303        layout_engine: &mut LayoutEngine,
304        scroll_map: &ScrollStateMap,
305    ) -> Result<usize> {
306        let viewport_changed = self.last_viewport.map(|v| v != viewport).unwrap_or(true);
307        let needs_full =
308            self.pending_layout_full || self.last_snapshot.is_none() || viewport_changed;
309
310        if !needs_full && !self.pending_layout_invalidated {
311            self.last_viewport = Some(viewport);
312            return Ok(0);
313        }
314
315        let start_layout = Instant::now();
316        let dirty_layout_nodes = if needs_full {
317            self.layout_input_nodes.len()
318        } else {
319            self.pending_layout_dirty_nodes.len()
320        };
321        let (snapshot, full_rebuild) = if needs_full {
322            self.layout_full_rebuild_count = self.layout_full_rebuild_count.saturating_add(1);
323            layout_engine.update(&self.layout_input_nodes);
324            let root_id = self
325                .prev_ir
326                .as_ref()
327                .and_then(|ir| ir.root)
328                .expect("no root in IR");
329            (
330                layout_engine.compute_layout(
331                    &self.layout_input_nodes,
332                    root_id,
333                    viewport.size,
334                    &|id| scroll_map.get_offset(id),
335                )?,
336                true,
337            )
338        } else {
339            layout_engine.update(&self.layout_input_nodes);
340            let root_id = self
341                .prev_ir
342                .as_ref()
343                .and_then(|ir| ir.root)
344                .expect("no root in IR");
345            (
346                layout_engine.compute_layout_incremental(
347                    &self.layout_input_nodes,
348                    root_id,
349                    viewport.size,
350                    &|id| scroll_map.get_offset(id),
351                    self.last_snapshot
352                        .as_ref()
353                        .expect("incremental layout requires a prior snapshot"),
354                    &self.pending_layout_dirty_nodes,
355                )?,
356                false,
357            )
358        };
359        self.last_snapshot = Some(snapshot);
360        self.last_viewport = Some(viewport);
361        self.pending_layout_dirty_nodes.clear();
362        self.pending_layout_invalidated = false;
363        self.pending_layout_full = false;
364        self.clear_render_caches();
365
366        let duration = start_layout.elapsed().as_nanos() as u64;
367        diag::emit(
368            diag::DiagCategory::Layout,
369            diag::DiagLevel::Debug,
370            diag::DiagEventKind::LayoutSummary {
371                nodes: self.layout_input_nodes.len() as u32,
372                dirty_count: dirty_layout_nodes as u32,
373                full_rebuild,
374                duration_ns: duration,
375            },
376        );
377
378        Ok(dirty_layout_nodes)
379    }
380
381    pub fn prepare_current(
382        &mut self,
383        render_viewport_size: LayoutSize,
384        layout_viewport_size: LayoutSize,
385        resize_preview: bool,
386        scroll_map: &ScrollStateMap,
387        animation_map: &AnimationStateMap,
388        video_map: &VideoStateMap,
389        web_map: &WebStateMap,
390    ) -> Result<PipelineStats> {
391        let render_viewport = LayoutRect::new(
392            0.0,
393            0.0,
394            render_viewport_size.width,
395            render_viewport_size.height,
396        );
397        let mut stats = PipelineStats {
398            dirty_nodes: if self.pending_layout_full || self.pending_layout_invalidated {
399                if self.pending_layout_full {
400                    self.layout_input_nodes.len()
401                } else {
402                    self.pending_layout_dirty_nodes.len()
403                }
404            } else {
405                0
406            },
407            layout_updates: 0,
408            paint_misses: 0,
409            paint_hits: 0,
410            video_surfaces: 0,
411        };
412
413        let ir = self.prev_ir.as_ref().expect("ir missing before render");
414        let snapshot = self
415            .last_snapshot
416            .as_ref()
417            .expect("snapshot missing before render");
418
419        self.video_surfaces.clear();
420        self.web_surfaces.clear();
421        self.scene_3d_surfaces.clear();
422        if let Some(root) = ir.root {
423            collect_video_surfaces(
424                root,
425                ir,
426                snapshot,
427                video_map,
428                web_map,
429                scroll_map,
430                LayoutPoint::ZERO,
431                &mut self.video_surfaces,
432                &mut self.web_surfaces,
433                &mut self.scene_3d_surfaces,
434            );
435        }
436        stats.video_surfaces = self.video_surfaces.len();
437
438        if self.retained_scene.is_none() {
439            if render_trace_enabled() {
440                eprintln!("[pipeline] rebuilding retained render scene");
441            }
442            if let Some(root) = ir.root {
443                let mut visited = HashSet::new();
444                let mut bindings = RetainedDynamicOps::default();
445                let content_root = generate_render_layer_recursive(
446                    root,
447                    ir,
448                    snapshot,
449                    scroll_map,
450                    animation_map,
451                    &mut self.paint_cache,
452                    &mut self.boundary_cache,
453                    &self.runtime_dynamic_subtrees,
454                    &mut stats.paint_misses,
455                    &mut stats.paint_hits,
456                    true,
457                    &mut visited,
458                    &mut bindings,
459                    vec![0, 0],
460                );
461                if let Some(content_root) = content_root {
462                    let mut presentation_root = RenderLayer::new(render_viewport);
463                    presentation_root.style.clip = Some(LayerClip::Rect(render_viewport));
464                    presentation_root
465                        .children
466                        .push(RenderNode::Layer(content_root));
467
468                    let mut scene = RenderScene::new(render_viewport);
469                    scene.roots.push(RenderNode::Layer(presentation_root));
470                    self.retained_scene = Some(scene);
471                    self.retained_dynamic_ops = bindings;
472                }
473            }
474        }
475
476        self.patch_retained_scene(
477            render_viewport_size,
478            layout_viewport_size,
479            resize_preview,
480            scroll_map,
481            animation_map,
482        );
483        let scene = self
484            .retained_scene
485            .as_ref()
486            .expect("retained render scene missing before render");
487        self.retained_texture_root_transform = scene.roots.first().and_then(|root| match root {
488            RenderNode::Layer(layer) => layer.style.transform,
489            RenderNode::Paint(_) => None,
490        });
491        if self.retained_texture_plans.is_empty() {
492            self.retained_texture_plans = self.build_texture_compositor_plans(scene);
493        } else {
494            patch_texture_compositor_plans(&mut self.retained_texture_plans, scene);
495        }
496
497        diag::emit(
498            diag::DiagCategory::Layout,
499            diag::DiagLevel::Debug,
500            diag::DiagEventKind::PaintSummary {
501                segments_reused: stats.paint_hits as u32,
502                segments_regenerated: stats.paint_misses as u32,
503                paint_ops_total: count_render_paint_ops(scene) as u32,
504            },
505        );
506
507        self.last_scroll_offsets = scroll_map
508            .offsets
509            .iter()
510            .map(|(id, offset)| (*id, offset.to_bits()))
511            .collect();
512
513        Ok(stats)
514    }
515
516    pub fn render_current(
517        &mut self,
518        render_viewport_size: LayoutSize,
519        layout_viewport_size: LayoutSize,
520        resize_preview: bool,
521        renderer: &mut dyn Renderer,
522        scroll_map: &ScrollStateMap,
523        animation_map: &AnimationStateMap,
524        video_map: &VideoStateMap,
525        web_map: &WebStateMap,
526    ) -> Result<PipelineStats> {
527        let stats = self.prepare_current(
528            render_viewport_size,
529            layout_viewport_size,
530            resize_preview,
531            scroll_map,
532            animation_map,
533            video_map,
534            web_map,
535        )?;
536        let scene = self
537            .retained_scene
538            .as_ref()
539            .expect("retained render scene missing before render");
540        renderer.render_scene(scene)?;
541        Ok(stats)
542    }
543
544    pub fn render(
545        &mut self,
546        next_ir: CoreIR,
547        viewport_size: LayoutSize,
548        layout_engine: &mut LayoutEngine,
549        scroll_map: &ScrollStateMap,
550        renderer: &mut dyn Renderer,
551        video_map: &VideoStateMap,
552        web_map: &WebStateMap,
553        env: &Env,
554    ) -> Result<PipelineStats> {
555        self.replace_ir(next_ir, env);
556        let viewport = LayoutRect::new(0.0, 0.0, viewport_size.width, viewport_size.height);
557        let layout_updates = self.ensure_layout(viewport, layout_engine, scroll_map)?;
558        let mut stats = self.render_current(
559            viewport_size,
560            viewport_size,
561            false,
562            renderer,
563            scroll_map,
564            &AnimationStateMap::default(),
565            video_map,
566            web_map,
567        )?;
568        stats.layout_updates = layout_updates;
569        Ok(stats)
570    }
571
572    fn refresh_retained_metadata(&mut self) {
573        self.compositor_animation_keys.clear();
574        self.runtime_dynamic_nodes.clear();
575        self.scroll_nodes.clear();
576        self.runtime_dynamic_subtrees.clear();
577        self.boundary_cache.clear();
578
579        let Some(ir) = self.prev_ir.as_ref() else {
580            return;
581        };
582
583        for node in ir.nodes.values() {
584            let mut node_is_runtime_dynamic =
585                matches!(node.op, Op::Layout(LayoutOp::Scroll { .. }));
586            if matches!(node.op, Op::Layout(LayoutOp::Scroll { .. })) {
587                self.scroll_nodes.insert(node.id);
588            }
589            if ir
590                .custom_render_objects
591                .get(&node.id)
592                .and_then(downcast_render_object)
593                .is_some_and(|render_object| render_object.is_runtime_dynamic())
594            {
595                node_is_runtime_dynamic = true;
596            }
597            if let Some(target) = node
598                .composite
599                .opacity
600                .as_ref()
601                .and_then(|value| value.animation_target)
602            {
603                self.compositor_animation_keys
604                    .insert((target, AnimationPropertyId::Opacity));
605                node_is_runtime_dynamic = true;
606            }
607            if let Some(target) = node
608                .composite
609                .translate_x
610                .as_ref()
611                .and_then(|value| value.animation_target)
612            {
613                self.compositor_animation_keys
614                    .insert((target, AnimationPropertyId::TranslateX));
615                node_is_runtime_dynamic = true;
616            }
617            if let Some(target) = node
618                .composite
619                .translate_y
620                .as_ref()
621                .and_then(|value| value.animation_target)
622            {
623                self.compositor_animation_keys
624                    .insert((target, AnimationPropertyId::TranslateY));
625                node_is_runtime_dynamic = true;
626            }
627            if let Some(target) = node
628                .composite
629                .scale
630                .as_ref()
631                .and_then(|value| value.animation_target)
632            {
633                self.compositor_animation_keys
634                    .insert((target, AnimationPropertyId::Scale));
635                node_is_runtime_dynamic = true;
636            }
637            if let Some(target) = node
638                .composite
639                .rotation
640                .as_ref()
641                .and_then(|value| value.animation_target)
642            {
643                self.compositor_animation_keys
644                    .insert((target, AnimationPropertyId::Rotation));
645                node_is_runtime_dynamic = true;
646            }
647            if node_is_runtime_dynamic {
648                self.runtime_dynamic_nodes.insert(node.id);
649            }
650        }
651
652        if let Some(root) = ir.root {
653            let mut memo = HashMap::new();
654            let _ = self.compute_runtime_dynamic_subtree(root, ir, &mut memo);
655            self.runtime_dynamic_subtrees = memo;
656        }
657    }
658
659    fn compute_runtime_dynamic_subtree(
660        &self,
661        node_id: WidgetId,
662        ir: &CoreIR,
663        memo: &mut HashMap<WidgetId, bool>,
664    ) -> bool {
665        if let Some(cached) = memo.get(&node_id) {
666            return *cached;
667        }
668
669        let Some(node) = ir.nodes.get(&node_id) else {
670            memo.insert(node_id, false);
671            return false;
672        };
673
674        let mut dynamic = matches!(node.op, Op::Layout(LayoutOp::Scroll { .. }));
675        dynamic |= node
676            .composite
677            .opacity
678            .as_ref()
679            .and_then(|value| value.animation_target)
680            .is_some();
681        dynamic |= node
682            .composite
683            .translate_x
684            .as_ref()
685            .and_then(|value| value.animation_target)
686            .is_some();
687        dynamic |= node
688            .composite
689            .translate_y
690            .as_ref()
691            .and_then(|value| value.animation_target)
692            .is_some();
693        dynamic |= node
694            .composite
695            .scale
696            .as_ref()
697            .and_then(|value| value.animation_target)
698            .is_some();
699        dynamic |= node
700            .composite
701            .rotation
702            .as_ref()
703            .and_then(|value| value.animation_target)
704            .is_some();
705
706        for child in &node.children {
707            dynamic |= self.compute_runtime_dynamic_subtree(*child, ir, memo);
708        }
709
710        memo.insert(node_id, dynamic);
711        dynamic
712    }
713
714    fn clear_render_caches(&mut self) {
715        if render_trace_enabled() {
716            eprintln!(
717                "[pipeline] clear_render_caches layout_full={} layout_invalidated={} retained_was_present={}",
718                self.pending_layout_full,
719                self.pending_layout_invalidated,
720                self.retained_scene.is_some()
721            );
722        }
723        self.paint_cache.clear();
724        self.boundary_cache.clear();
725        self.retained_scene = None;
726        self.retained_dynamic_ops = RetainedDynamicOps::default();
727        self.retained_texture_plans.clear();
728        self.retained_texture_root_transform = None;
729    }
730
731    fn patch_retained_scene(
732        &mut self,
733        render_viewport_size: LayoutSize,
734        layout_viewport_size: LayoutSize,
735        resize_preview: bool,
736        scroll_map: &ScrollStateMap,
737        animation_map: &AnimationStateMap,
738    ) {
739        let Some(scene) = self.retained_scene.as_mut() else {
740            return;
741        };
742
743        scene.bounds = LayoutRect::new(
744            0.0,
745            0.0,
746            render_viewport_size.width,
747            render_viewport_size.height,
748        );
749        let scene_bounds = scene.bounds;
750        if let Some(presentation_layer) = layer_mut_at_path(scene, &[0]) {
751            presentation_layer.bounds = scene_bounds;
752            presentation_layer.style.clip = Some(LayerClip::Rect(scene_bounds));
753            presentation_layer.style.transform = presentation_transform_matrix(
754                render_viewport_size,
755                layout_viewport_size,
756                resize_preview,
757            );
758        }
759
760        for binding in &self.retained_dynamic_ops.opacity {
761            let alpha =
762                resolve_scalar_value(&binding.scalar, animation_map, AnimationPropertyId::Opacity);
763            if let Some(layer) = layer_mut_at_path(scene, &binding.layer_path) {
764                layer.style.opacity = alpha;
765            }
766        }
767
768        for binding in &self.retained_dynamic_ops.transform {
769            if let Some(layer) = layer_mut_at_path(scene, &binding.layer_path) {
770                layer.style.transform =
771                    compose_dynamic_layer_transform(binding, scroll_map, animation_map);
772            }
773        }
774
775        let Some(ir) = self.prev_ir.as_ref() else {
776            return;
777        };
778        let Some(snapshot) = self.last_snapshot.as_ref() else {
779            return;
780        };
781        for binding in &self.retained_dynamic_ops.scrollbar {
782            let Some(scrollbar) = build_scrollbar_paint(ir, binding.node_id, snapshot, scroll_map)
783            else {
784                continue;
785            };
786            if let Some(RenderNode::Paint(list)) =
787                render_node_mut_at_path(scene, &binding.node_path)
788            {
789                *list = scrollbar;
790            }
791        }
792    }
793
794    pub fn retained_scene(&self) -> Option<&RenderScene> {
795        self.retained_scene.as_ref()
796    }
797
798    pub fn texture_compositor_plans(&self) -> &[CompositorTexturePlan] {
799        &self.retained_texture_plans
800    }
801
802    pub fn texture_compositor_root_transform(&self) -> Option<[f32; 16]> {
803        self.retained_texture_root_transform
804    }
805
806    fn build_texture_compositor_plans(&self, scene: &RenderScene) -> Vec<CompositorTexturePlan> {
807        let Some(split_layer_path) = find_texture_compositor_split_layer_path(scene) else {
808            return Vec::new();
809        };
810        let Some(split_layer) = layer_ref_at_path(scene, &split_layer_path) else {
811            return Vec::new();
812        };
813        let mut plans = Vec::new();
814        for (child_index, child) in split_layer.children.iter().enumerate() {
815            let mut child_path = split_layer_path.clone();
816            child_path.push(child_index);
817            if let Some(plan) = build_texture_plan_for_node(
818                child,
819                &child_path,
820                true,
821                &self.runtime_dynamic_nodes,
822                &self.scroll_nodes,
823                &self.runtime_dynamic_subtrees,
824            ) {
825                plans.push(plan);
826            }
827        }
828        if render_trace_enabled() {
829            for plan in &plans {
830                log_texture_plan(plan, 0);
831            }
832        }
833        plans
834    }
835}
836
837fn log_texture_plan(plan: &CompositorTexturePlan, depth: usize) {
838    let indent = "  ".repeat(depth);
839    eprintln!(
840        "[pipeline] {}plan key={} bounds=({}, {}, {}x{}) scene={} clip={} transform=({:.1},{:.1}) transform_clip={} children={}",
841        indent,
842        plan.key,
843        plan.bounds.origin.x,
844        plan.bounds.origin.y,
845        plan.bounds.size.width,
846        plan.bounds.size.height,
847        plan.scene.is_some(),
848        plan.clip.is_some(),
849        plan.transform.map(|m| m[12]).unwrap_or(0.0),
850        plan.transform.map(|m| m[13]).unwrap_or(0.0),
851        plan.transform_clip,
852        plan.children.len()
853    );
854    for child in &plan.children {
855        log_texture_plan(child, depth + 1);
856    }
857}
858
859fn layer_mut_at_path<'a>(
860    scene: &'a mut RenderScene,
861    path: &[usize],
862) -> Option<&'a mut RenderLayer> {
863    let (root_index, tail) = path.split_first()?;
864    let node = scene.roots.get_mut(*root_index)?;
865    layer_mut_in_node(node, tail)
866}
867
868fn render_node_mut_at_path<'a>(
869    scene: &'a mut RenderScene,
870    path: &[usize],
871) -> Option<&'a mut RenderNode> {
872    let (root_index, tail) = path.split_first()?;
873    let node = scene.roots.get_mut(*root_index)?;
874    render_node_mut_in_node(node, tail)
875}
876
877fn render_node_mut_in_node<'a>(
878    node: &'a mut RenderNode,
879    path: &[usize],
880) -> Option<&'a mut RenderNode> {
881    if path.is_empty() {
882        return Some(node);
883    }
884    match node {
885        RenderNode::Layer(layer) => {
886            let (child_index, tail) = path.split_first()?;
887            let child = layer.children.get_mut(*child_index)?;
888            render_node_mut_in_node(child, tail)
889        }
890        RenderNode::Paint(_) => None,
891    }
892}
893
894fn layer_mut_in_node<'a>(node: &'a mut RenderNode, path: &[usize]) -> Option<&'a mut RenderLayer> {
895    match node {
896        RenderNode::Layer(layer) => {
897            if path.is_empty() {
898                return Some(layer);
899            }
900            let (child_index, tail) = path.split_first()?;
901            let child = layer.children.get_mut(*child_index)?;
902            layer_mut_in_node(child, tail)
903        }
904        RenderNode::Paint(_) => None,
905    }
906}
907
908fn layer_ref_at_path<'a>(scene: &'a RenderScene, path: &[usize]) -> Option<&'a RenderLayer> {
909    let (root_index, tail) = path.split_first()?;
910    let node = scene.roots.get(*root_index)?;
911    layer_ref_in_node(node, tail)
912}
913
914fn layer_ref_in_node<'a>(node: &'a RenderNode, path: &[usize]) -> Option<&'a RenderLayer> {
915    match node {
916        RenderNode::Layer(layer) => {
917            if path.is_empty() {
918                return Some(layer);
919            }
920            let (child_index, tail) = path.split_first()?;
921            let child = layer.children.get(*child_index)?;
922            layer_ref_in_node(child, tail)
923        }
924        RenderNode::Paint(_) => None,
925    }
926}
927
928fn count_render_paint_ops(scene: &RenderScene) -> usize {
929    scene.roots.iter().map(count_render_node_paint_ops).sum()
930}
931
932fn count_render_node_paint_ops(node: &RenderNode) -> usize {
933    match node {
934        RenderNode::Paint(list) => list.ops.len(),
935        RenderNode::Layer(layer) => layer.children.iter().map(count_render_node_paint_ops).sum(),
936    }
937}
938
939fn render_node_bounds(node: &RenderNode) -> LayoutRect {
940    match node {
941        RenderNode::Paint(list) => list.bounds,
942        RenderNode::Layer(layer) => layer.bounds,
943    }
944}
945
946fn find_texture_compositor_split_layer_path(scene: &RenderScene) -> Option<Vec<usize>> {
947    let Some(RenderNode::Layer(presentation_root)) = scene.roots.first() else {
948        return None;
949    };
950    if presentation_root.children.len() != 1 {
951        return None;
952    }
953    let Some(RenderNode::Layer(layer)) = presentation_root.children.first() else {
954        return None;
955    };
956    let mut layer = layer;
957    let mut path = vec![0, 0];
958    loop {
959        let only_child = match layer.children.as_slice() {
960            [RenderNode::Layer(child)] => Some(child),
961            _ => None,
962        };
963        let is_plain_wrapper = layer.style.clip.is_none()
964            && (layer.style.opacity - 1.0).abs() <= 0.001
965            && layer.style.transform.is_none();
966        if let (true, Some(child)) = (is_plain_wrapper, only_child) {
967            layer = child;
968            path.push(0);
969        } else {
970            return Some(path);
971        }
972    }
973}
974
975#[derive(Debug)]
976struct TexturePlanCandidate<'a> {
977    node: &'a RenderNode,
978    path: Vec<usize>,
979}
980
981fn build_texture_plan_for_node(
982    node: &RenderNode,
983    node_path: &[usize],
984    force: bool,
985    runtime_dynamic_nodes: &HashSet<WidgetId>,
986    scroll_nodes: &HashSet<WidgetId>,
987    runtime_dynamic_subtrees: &HashMap<WidgetId, bool>,
988) -> Option<CompositorTexturePlan> {
989    let candidate = find_nested_texture_plan_candidate(
990        node,
991        node_path,
992        force,
993        runtime_dynamic_nodes,
994        scroll_nodes,
995        runtime_dynamic_subtrees,
996    )?;
997    let bounds = render_node_bounds(candidate.node);
998    if bounds.size.width <= 0.0 || bounds.size.height <= 0.0 {
999        return None;
1000    }
1001
1002    match candidate.node {
1003        RenderNode::Paint(list) => {
1004            let scene = localized_scene_for_compositor_children(
1005                vec![RenderNode::Paint(list.clone())],
1006                bounds,
1007            );
1008            let scene_cache_key = scene_cache_key(&scene);
1009            let content_key = plan_content_key(Some(scene_cache_key), &[]);
1010            Some(CompositorTexturePlan {
1011                key: texture_plan_key_for_paint(list),
1012                bounds,
1013                scene: Some(scene),
1014                scene_cache_key: Some(scene_cache_key),
1015                content_key,
1016                local_dynamic: false,
1017                composite_dynamic: false,
1018                opacity: 1.0,
1019                transform: None,
1020                transform_clip: true,
1021                clip: None,
1022                children: Vec::new(),
1023                source_layer_path: None,
1024            })
1025        }
1026        RenderNode::Layer(layer) => {
1027            let wrapper_only_scroll_plan = !layer.style.transform_clip;
1028            let mut child_plans = Vec::new();
1029            let mut local_children = Vec::new();
1030            for (child_index, child) in layer.children.iter().enumerate() {
1031                let mut child_path = candidate.path.clone();
1032                child_path.push(child_index);
1033                if wrapper_only_scroll_plan {
1034                    child_plans.extend(build_descending_wrapper_plans(
1035                        child,
1036                        &child_path,
1037                        runtime_dynamic_nodes,
1038                        scroll_nodes,
1039                        runtime_dynamic_subtrees,
1040                    ));
1041                } else {
1042                    if let Some(child_plan) = build_texture_plan_for_node(
1043                        child,
1044                        &child_path,
1045                        false,
1046                        runtime_dynamic_nodes,
1047                        scroll_nodes,
1048                        runtime_dynamic_subtrees,
1049                    ) {
1050                        child_plans.push(child_plan);
1051                    } else {
1052                        local_children.push(child.clone());
1053                    }
1054                }
1055            }
1056
1057            let local_dynamic = local_children
1058                .iter()
1059                .any(|child| render_node_or_subtree_is_dynamic(child, runtime_dynamic_subtrees));
1060            let scene = if local_children.is_empty() {
1061                None
1062            } else {
1063                Some(localized_scene_for_compositor_children(
1064                    local_children,
1065                    bounds,
1066                ))
1067            };
1068            let scene_cache_key = if scene.is_none() {
1069                None
1070            } else {
1071                layer
1072                    .style
1073                    .content_cache_key
1074                    .or(layer.style.cache_key)
1075                    .or_else(|| scene.as_ref().map(scene_cache_key))
1076            };
1077            let content_key = plan_content_key(scene_cache_key, &child_plans);
1078            let composite_dynamic = layer
1079                .node_id
1080                .map(|id| runtime_dynamic_nodes.contains(&id))
1081                .unwrap_or(false);
1082            Some(CompositorTexturePlan {
1083                key: texture_plan_key_for_layer(layer),
1084                bounds,
1085                scene,
1086                scene_cache_key,
1087                content_key,
1088                local_dynamic,
1089                composite_dynamic,
1090                opacity: layer.style.opacity,
1091                transform: layer.style.transform,
1092                transform_clip: layer.style.transform_clip,
1093                clip: layer.style.clip.clone(),
1094                children: child_plans,
1095                source_layer_path: Some(candidate.path),
1096            })
1097        }
1098    }
1099}
1100
1101fn build_descending_wrapper_plans(
1102    node: &RenderNode,
1103    node_path: &[usize],
1104    runtime_dynamic_nodes: &HashSet<WidgetId>,
1105    scroll_nodes: &HashSet<WidgetId>,
1106    runtime_dynamic_subtrees: &HashMap<WidgetId, bool>,
1107) -> Vec<CompositorTexturePlan> {
1108    match node {
1109        RenderNode::Paint(_) => build_texture_plan_for_node(
1110            node,
1111            node_path,
1112            true,
1113            runtime_dynamic_nodes,
1114            scroll_nodes,
1115            runtime_dynamic_subtrees,
1116        )
1117        .into_iter()
1118        .collect(),
1119        RenderNode::Layer(layer) => {
1120            let mut children = Vec::new();
1121            for (child_index, child) in layer.children.iter().enumerate() {
1122                let mut child_path = node_path.to_vec();
1123                child_path.push(child_index);
1124                children.extend(build_descending_wrapper_plans(
1125                    child,
1126                    &child_path,
1127                    runtime_dynamic_nodes,
1128                    scroll_nodes,
1129                    runtime_dynamic_subtrees,
1130                ));
1131            }
1132
1133            if children.is_empty() {
1134                return build_texture_plan_for_node(
1135                    node,
1136                    node_path,
1137                    true,
1138                    runtime_dynamic_nodes,
1139                    scroll_nodes,
1140                    runtime_dynamic_subtrees,
1141                )
1142                .into_iter()
1143                .collect();
1144            }
1145
1146            let composite_dynamic = layer
1147                .node_id
1148                .map(|id| runtime_dynamic_nodes.contains(&id))
1149                .unwrap_or(false);
1150            vec![CompositorTexturePlan {
1151                key: texture_plan_key_for_layer(layer),
1152                bounds: layer.bounds,
1153                scene: None,
1154                scene_cache_key: None,
1155                content_key: plan_content_key(None, &children),
1156                local_dynamic: false,
1157                composite_dynamic,
1158                opacity: layer.style.opacity,
1159                transform: layer.style.transform,
1160                transform_clip: layer.style.transform_clip,
1161                clip: layer.style.clip.clone(),
1162                children,
1163                source_layer_path: Some(node_path.to_vec()),
1164            }]
1165        }
1166    }
1167}
1168
1169fn find_nested_texture_plan_candidate<'a>(
1170    node: &'a RenderNode,
1171    node_path: &[usize],
1172    force: bool,
1173    runtime_dynamic_nodes: &HashSet<WidgetId>,
1174    scroll_nodes: &HashSet<WidgetId>,
1175    runtime_dynamic_subtrees: &HashMap<WidgetId, bool>,
1176) -> Option<TexturePlanCandidate<'a>> {
1177    match node {
1178        RenderNode::Paint(_) => force.then_some(TexturePlanCandidate {
1179            node,
1180            path: node_path.to_vec(),
1181        }),
1182        RenderNode::Layer(layer) => {
1183            if !force {
1184                if let Some(child) = descend_through_plain_wrapper(layer) {
1185                    let mut child_path = node_path.to_vec();
1186                    child_path.push(0);
1187                    return find_nested_texture_plan_candidate(
1188                        child,
1189                        &child_path,
1190                        false,
1191                        runtime_dynamic_nodes,
1192                        scroll_nodes,
1193                        runtime_dynamic_subtrees,
1194                    );
1195                }
1196            }
1197
1198            let subtree_dynamic = render_node_or_subtree_is_dynamic(node, runtime_dynamic_subtrees);
1199            let own_dynamic = layer
1200                .node_id
1201                .map(|id| runtime_dynamic_nodes.contains(&id))
1202                .unwrap_or(false);
1203            let is_scroll_node = layer
1204                .node_id
1205                .map(|id| scroll_nodes.contains(&id))
1206                .unwrap_or(false);
1207            if force
1208                || layer_should_extract_as_plan(layer, subtree_dynamic, own_dynamic, is_scroll_node)
1209            {
1210                Some(TexturePlanCandidate {
1211                    node,
1212                    path: node_path.to_vec(),
1213                })
1214            } else {
1215                for (child_index, child) in layer.children.iter().enumerate() {
1216                    let mut child_path = node_path.to_vec();
1217                    child_path.push(child_index);
1218                    if let Some(candidate) = find_nested_texture_plan_candidate(
1219                        child,
1220                        &child_path,
1221                        false,
1222                        runtime_dynamic_nodes,
1223                        scroll_nodes,
1224                        runtime_dynamic_subtrees,
1225                    ) {
1226                        return Some(candidate);
1227                    }
1228                }
1229                None
1230            }
1231        }
1232    }
1233}
1234
1235fn descend_through_plain_wrapper<'a>(layer: &'a RenderLayer) -> Option<&'a RenderNode> {
1236    let only_child = match layer.children.as_slice() {
1237        [child] => Some(child),
1238        _ => None,
1239    }?;
1240    if layer.style.clip.is_none()
1241        && (layer.style.opacity - 1.0).abs() <= 0.001
1242        && layer.style.transform.is_none()
1243    {
1244        match only_child {
1245            RenderNode::Layer(_) => Some(only_child),
1246            RenderNode::Paint(_) => None,
1247        }
1248    } else {
1249        None
1250    }
1251}
1252
1253fn layer_should_extract_as_plan(
1254    layer: &RenderLayer,
1255    subtree_dynamic: bool,
1256    own_dynamic: bool,
1257    is_scroll_node: bool,
1258) -> bool {
1259    const MIN_PLAN_AREA: f32 = 64.0 * 64.0;
1260    if layer.children.is_empty() {
1261        return false;
1262    }
1263    if is_scroll_node {
1264        return false;
1265    }
1266    if own_dynamic {
1267        return true;
1268    }
1269    if !subtree_dynamic {
1270        return false;
1271    }
1272    let has_style = layer.style.clip.is_some()
1273        || (layer.style.opacity - 1.0).abs() > 0.001
1274        || layer.style.transform.is_some();
1275    let has_local_paint = layer
1276        .children
1277        .iter()
1278        .any(|child| matches!(child, RenderNode::Paint(_)));
1279    let has_multiple_children = layer.children.len() > 1;
1280    (has_style || has_local_paint || has_multiple_children)
1281        && layer.bounds.size.width * layer.bounds.size.height >= MIN_PLAN_AREA
1282}
1283
1284fn localized_scene_for_compositor_children(
1285    children: Vec<RenderNode>,
1286    bounds: LayoutRect,
1287) -> RenderScene {
1288    let local_bounds = LayoutRect::new(0.0, 0.0, bounds.size.width, bounds.size.height);
1289    let mut root = RenderLayer::new(local_bounds);
1290    root.style.transform = Some(translation_matrix(-bounds.origin.x, -bounds.origin.y));
1291    root.children.extend(children);
1292
1293    let mut scene = RenderScene::new(local_bounds);
1294    scene.roots.push(RenderNode::Layer(root));
1295    scene
1296}
1297
1298fn render_node_or_subtree_is_dynamic(
1299    node: &RenderNode,
1300    runtime_dynamic_subtrees: &HashMap<WidgetId, bool>,
1301) -> bool {
1302    match node {
1303        RenderNode::Paint(_) => false,
1304        RenderNode::Layer(layer) => {
1305            layer
1306                .node_id
1307                .and_then(|id| runtime_dynamic_subtrees.get(&id).copied())
1308                .unwrap_or(false)
1309                || layer
1310                    .children
1311                    .iter()
1312                    .any(|child| render_node_or_subtree_is_dynamic(child, runtime_dynamic_subtrees))
1313        }
1314    }
1315}
1316
1317fn texture_plan_key_for_layer(layer: &RenderLayer) -> u64 {
1318    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1319    layer.node_id.hash(&mut hasher);
1320    layer.bounds.size.width.to_bits().hash(&mut hasher);
1321    layer.bounds.size.height.to_bits().hash(&mut hasher);
1322    hash_serde_value(&layer.style.clip, &mut hasher);
1323    hasher.finish()
1324}
1325
1326fn texture_plan_key_for_paint(list: &DisplayList) -> u64 {
1327    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1328    list.bounds.size.width.to_bits().hash(&mut hasher);
1329    list.bounds.size.height.to_bits().hash(&mut hasher);
1330    hash_serde_value(list, &mut hasher);
1331    hasher.finish()
1332}
1333
1334fn scene_cache_key(scene: &RenderScene) -> u64 {
1335    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1336    hash_serde_value(scene, &mut hasher);
1337    hasher.finish()
1338}
1339
1340fn plan_content_key(scene_cache_key: Option<u64>, children: &[CompositorTexturePlan]) -> u64 {
1341    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1342    scene_cache_key.hash(&mut hasher);
1343    for child in children {
1344        child.key.hash(&mut hasher);
1345        child.content_key.hash(&mut hasher);
1346        child.bounds.origin.x.to_bits().hash(&mut hasher);
1347        child.bounds.origin.y.to_bits().hash(&mut hasher);
1348        child.bounds.size.width.to_bits().hash(&mut hasher);
1349        child.bounds.size.height.to_bits().hash(&mut hasher);
1350        child.opacity.to_bits().hash(&mut hasher);
1351        hash_serde_value(&child.transform, &mut hasher);
1352        hash_serde_value(&child.clip, &mut hasher);
1353    }
1354    hasher.finish()
1355}
1356
1357fn patch_texture_compositor_plans(plans: &mut [CompositorTexturePlan], scene: &RenderScene) {
1358    for plan in plans {
1359        patch_texture_compositor_plan(plan, scene);
1360    }
1361}
1362
1363fn patch_texture_compositor_plan(plan: &mut CompositorTexturePlan, scene: &RenderScene) {
1364    for child in &mut plan.children {
1365        patch_texture_compositor_plan(child, scene);
1366    }
1367
1368    if let Some(path) = plan.source_layer_path.as_deref() {
1369        if let Some(layer) = layer_ref_at_path(scene, path) {
1370            plan.bounds = layer.bounds;
1371            plan.opacity = layer.style.opacity;
1372            plan.transform = layer.style.transform;
1373            plan.transform_clip = layer.style.transform_clip;
1374            plan.clip = layer.style.clip.clone();
1375        }
1376    }
1377
1378    plan.content_key = plan_content_key(plan.scene_cache_key, &plan.children);
1379}
1380
1381fn hash_serde_value<T: Serialize, H: Hasher>(value: &T, hasher: &mut H) {
1382    if let Ok(bytes) = bincode::serialize(value) {
1383        bytes.hash(hasher);
1384    }
1385}
1386
1387fn presentation_transform_matrix(
1388    render_viewport_size: LayoutSize,
1389    layout_viewport_size: LayoutSize,
1390    resize_preview: bool,
1391) -> Option<[f32; 16]> {
1392    if !resize_preview
1393        || render_viewport_size.width <= 0.0
1394        || render_viewport_size.height <= 0.0
1395        || layout_viewport_size.width <= 0.0
1396        || layout_viewport_size.height <= 0.0
1397    {
1398        return None;
1399    }
1400
1401    // Do not non-uniformly scale the retained UI during live resize.
1402    // Text-heavy surfaces look visibly distorted; we keep the last committed
1403    // layout anchored in place and rely on throttled relayouts instead.
1404    None
1405}
1406
1407fn compose_dynamic_layer_transform(
1408    binding: &TransformBinding,
1409    scroll_map: &ScrollStateMap,
1410    animation_map: &AnimationStateMap,
1411) -> Option<[f32; 16]> {
1412    let mut matrix: Option<[f32; 16]> = None;
1413
1414    if let Some(scroll) = &binding.scroll {
1415        let offset = scroll_map.get_offset(scroll.node_id);
1416        let scroll_matrix = match scroll.direction {
1417            FlexDirection::Row => translation_matrix(-offset, 0.0),
1418            FlexDirection::Column => translation_matrix(0.0, -offset),
1419        };
1420        matrix = append_transform(matrix, scroll_matrix);
1421    }
1422
1423    if let Some(layout_transform) = binding.layout_transform {
1424        matrix = append_transform(matrix, layout_transform);
1425    }
1426
1427    let translate_x = binding
1428        .translate_x
1429        .as_ref()
1430        .map(|scalar| resolve_scalar_value(scalar, animation_map, AnimationPropertyId::TranslateX))
1431        .unwrap_or(0.0);
1432    let translate_y = binding
1433        .translate_y
1434        .as_ref()
1435        .map(|scalar| resolve_scalar_value(scalar, animation_map, AnimationPropertyId::TranslateY))
1436        .unwrap_or(0.0);
1437    let scale = binding
1438        .scale
1439        .as_ref()
1440        .map(|scalar| resolve_scalar_value(scalar, animation_map, AnimationPropertyId::Scale))
1441        .unwrap_or(1.0);
1442    let rotation = binding
1443        .rotation
1444        .as_ref()
1445        .map(|scalar| resolve_scalar_value(scalar, animation_map, AnimationPropertyId::Rotation))
1446        .unwrap_or(0.0);
1447
1448    let has_composite_transform = translate_x.abs() > 0.001
1449        || translate_y.abs() > 0.001
1450        || (scale - 1.0).abs() > 0.001
1451        || rotation.abs() > 0.001;
1452    if has_composite_transform {
1453        matrix = append_transform(
1454            matrix,
1455            composite_transform_matrix(binding.rect, translate_x, translate_y, scale, rotation),
1456        );
1457    }
1458
1459    matrix.filter(|value| !is_identity_matrix(value))
1460}
1461
1462fn append_transform(current: Option<[f32; 16]>, next: [f32; 16]) -> Option<[f32; 16]> {
1463    Some(match current {
1464        Some(existing) => multiply_matrix(existing, next),
1465        None => next,
1466    })
1467}
1468
1469fn generate_render_layer_recursive(
1470    node_id: WidgetId,
1471    ir: &CoreIR,
1472    snapshot: &LayoutSnapshot,
1473    scroll_map: &ScrollStateMap,
1474    animation_map: &AnimationStateMap,
1475    paint_cache: &mut HashMap<WidgetId, (u64, DisplayList)>,
1476    boundary_cache: &mut HashMap<WidgetId, BoundaryCacheEntry>,
1477    runtime_dynamic_subtrees: &HashMap<WidgetId, bool>,
1478    miss_count: &mut usize,
1479    hit_count: &mut usize,
1480    scene_cache_allowed: bool,
1481    visited: &mut HashSet<WidgetId>,
1482    bindings: &mut RetainedDynamicOps,
1483    layer_path: Vec<usize>,
1484) -> Option<RenderLayer> {
1485    if !visited.insert(node_id) {
1486        return None;
1487    }
1488
1489    let (Some(node), Some(geom)) = (ir.nodes.get(&node_id), snapshot.nodes.get(&node_id)) else {
1490        return None;
1491    };
1492
1493    let rect = geom.rect;
1494    let can_use_boundary_cache = !runtime_dynamic_subtrees
1495        .get(&node_id)
1496        .copied()
1497        .unwrap_or(false);
1498
1499    let scene_cache_key = boundary_hash(node, rect);
1500    let can_cache_scene = scene_cache_allowed && can_use_boundary_cache && node.parent.is_some();
1501    if can_cache_scene {
1502        if let Some(entry) = boundary_cache.get(&node_id) {
1503            if entry.hash == scene_cache_key {
1504                *hit_count += 1;
1505                return Some(entry.layer.clone());
1506            }
1507        }
1508    } else if can_use_boundary_cache {
1509        if let Some(entry) = boundary_cache.get(&node_id) {
1510            if entry.hash == scene_cache_key {
1511                *hit_count += 1;
1512                return Some(entry.layer.clone());
1513            }
1514        }
1515    }
1516
1517    let composite_opacity = resolve_composite_scalar(
1518        node.composite.opacity.as_ref(),
1519        animation_map,
1520        AnimationPropertyId::Opacity,
1521    );
1522    let composite_tx = resolve_composite_scalar(
1523        node.composite.translate_x.as_ref(),
1524        animation_map,
1525        AnimationPropertyId::TranslateX,
1526    );
1527    let composite_ty = resolve_composite_scalar(
1528        node.composite.translate_y.as_ref(),
1529        animation_map,
1530        AnimationPropertyId::TranslateY,
1531    );
1532    let composite_scale = resolve_composite_scalar(
1533        node.composite.scale.as_ref(),
1534        animation_map,
1535        AnimationPropertyId::Scale,
1536    )
1537    .unwrap_or(1.0);
1538    let composite_rotation = resolve_composite_scalar(
1539        node.composite.rotation.as_ref(),
1540        animation_map,
1541        AnimationPropertyId::Rotation,
1542    )
1543    .unwrap_or(0.0);
1544
1545    let _has_composite_transform = composite_tx.unwrap_or(0.0).abs() > 0.001
1546        || composite_ty.unwrap_or(0.0).abs() > 0.001
1547        || (composite_scale - 1.0).abs() > 0.001
1548        || composite_rotation.abs() > 0.001;
1549    let has_opacity_layer = composite_opacity
1550        .map(|value| (value - 1.0).abs() > 0.001)
1551        .unwrap_or(false);
1552    let needs_dynamic_opacity = node
1553        .composite
1554        .opacity
1555        .as_ref()
1556        .and_then(|value| value.animation_target)
1557        .is_some();
1558    let needs_dynamic_transform = node
1559        .composite
1560        .translate_x
1561        .as_ref()
1562        .and_then(|value| value.animation_target)
1563        .is_some()
1564        || node
1565            .composite
1566            .translate_y
1567            .as_ref()
1568            .and_then(|value| value.animation_target)
1569            .is_some()
1570        || node
1571            .composite
1572            .scale
1573            .as_ref()
1574            .and_then(|value| value.animation_target)
1575            .is_some()
1576        || node
1577            .composite
1578            .rotation
1579            .as_ref()
1580            .and_then(|value| value.animation_target)
1581            .is_some();
1582    let emit_opacity_layer = has_opacity_layer || needs_dynamic_opacity;
1583    let has_runtime_clip = node.composite.clip_to_bounds;
1584    let scroll = match &node.op {
1585        Op::Layout(LayoutOp::Scroll { direction, .. }) => Some(ScrollTransform {
1586            node_id,
1587            direction: *direction,
1588        }),
1589        _ => None,
1590    };
1591    let layout_transform = match &node.op {
1592        Op::Layout(LayoutOp::Transform { transform }) => Some(*transform),
1593        _ => None,
1594    };
1595    let has_own_transform = needs_dynamic_transform || layout_transform.is_some();
1596    let has_dynamic_transform = has_own_transform || scroll.is_some();
1597    let has_dynamic_style = emit_opacity_layer || has_dynamic_transform || has_runtime_clip;
1598    let has_dynamic_children = node.children.iter().any(|child| {
1599        runtime_dynamic_subtrees
1600            .get(child)
1601            .copied()
1602            .unwrap_or(false)
1603    });
1604    let mut layer = RenderLayer::new(rect);
1605    layer.node_id = Some(node_id);
1606    if can_cache_scene {
1607        layer.style.cache_key = Some(scene_cache_key);
1608    } else if has_dynamic_style && !has_dynamic_children {
1609        layer.style.content_cache_key = Some(scene_cache_key ^ 0x9E37_79B9_7F4A_7C15);
1610    }
1611
1612    layer.style.clip = match &node.op {
1613        Op::Layout(LayoutOp::Scroll { .. }) | Op::Layout(LayoutOp::Clip { .. }) => {
1614            Some(LayerClip::Rect(rect))
1615        }
1616        _ if has_runtime_clip => Some(LayerClip::Rect(rect)),
1617        _ => None,
1618    };
1619    if emit_opacity_layer {
1620        layer.style.opacity = composite_opacity.unwrap_or(1.0);
1621    }
1622
1623    if let Some(transform) = compose_dynamic_layer_transform(
1624        &TransformBinding {
1625            layer_path: layer_path.clone(),
1626            rect,
1627            layout_transform,
1628            scroll: None,
1629            translate_x: node.composite.translate_x.clone(),
1630            translate_y: node.composite.translate_y.clone(),
1631            scale: node.composite.scale.clone(),
1632            rotation: node.composite.rotation.clone(),
1633        },
1634        scroll_map,
1635        animation_map,
1636    ) {
1637        layer.style.transform = Some(transform);
1638    }
1639
1640    let local_hash = local_paint_hash(node);
1641    let local_paint = if let Some((cached_hash, cached_ops)) = paint_cache.get(&node_id) {
1642        if *cached_hash == local_hash {
1643            *hit_count += 1;
1644            Some(cached_ops.clone())
1645        } else {
1646            *miss_count += 1;
1647            let ops = build_local_paint_list(ir, node_id, node, rect);
1648            if let Some(ops) = ops.clone() {
1649                paint_cache.insert(node_id, (local_hash, ops));
1650            } else {
1651                paint_cache.remove(&node_id);
1652            }
1653            ops
1654        }
1655    } else {
1656        *miss_count += 1;
1657        let ops = build_local_paint_list(ir, node_id, node, rect);
1658        if let Some(ops) = ops.clone() {
1659            paint_cache.insert(node_id, (local_hash, ops));
1660        }
1661        ops
1662    };
1663
1664    if let Some(local_paint) = local_paint {
1665        layer.children.push(RenderNode::Paint(local_paint));
1666    }
1667
1668    if needs_dynamic_opacity {
1669        if let Some(scalar) = node.composite.opacity.as_ref() {
1670            bindings.opacity.push(OpacityBinding {
1671                layer_path: layer_path.clone(),
1672                scalar: scalar.clone(),
1673            });
1674        }
1675    }
1676    if has_own_transform {
1677        bindings.transform.push(TransformBinding {
1678            layer_path: layer_path.clone(),
1679            rect,
1680            layout_transform,
1681            scroll: None,
1682            translate_x: node.composite.translate_x.clone(),
1683            translate_y: node.composite.translate_y.clone(),
1684            scale: node.composite.scale.clone(),
1685            rotation: node.composite.rotation.clone(),
1686        });
1687    }
1688
1689    if let Some(scroll) = scroll {
1690        let content_index = layer.children.len();
1691        let mut content_path = layer_path.clone();
1692        content_path.push(content_index);
1693        let mut content_layer = RenderLayer::new(rect);
1694        content_layer.style.transform = compose_dynamic_layer_transform(
1695            &TransformBinding {
1696                layer_path: content_path.clone(),
1697                rect,
1698                layout_transform: None,
1699                scroll: Some(scroll.clone()),
1700                translate_x: None,
1701                translate_y: None,
1702                scale: None,
1703                rotation: None,
1704            },
1705            scroll_map,
1706            animation_map,
1707        );
1708        content_layer.style.transform_clip = false;
1709        bindings.transform.push(TransformBinding {
1710            layer_path: content_path.clone(),
1711            rect,
1712            layout_transform: None,
1713            scroll: Some(scroll),
1714            translate_x: None,
1715            translate_y: None,
1716            scale: None,
1717            rotation: None,
1718        });
1719
1720        for child in &node.children {
1721            let child_index = content_layer.children.len();
1722            let mut child_path = content_path.clone();
1723            child_path.push(child_index);
1724            if let Some(child_layer) = generate_render_layer_recursive(
1725                *child,
1726                ir,
1727                snapshot,
1728                scroll_map,
1729                animation_map,
1730                paint_cache,
1731                boundary_cache,
1732                runtime_dynamic_subtrees,
1733                miss_count,
1734                hit_count,
1735                scene_cache_allowed,
1736                visited,
1737                bindings,
1738                child_path,
1739            ) {
1740                content_layer.children.push(RenderNode::Layer(child_layer));
1741            }
1742        }
1743
1744        if !content_layer.children.is_empty() {
1745            layer.children.push(RenderNode::Layer(content_layer));
1746        }
1747    } else {
1748        for child in &node.children {
1749            let child_index = layer.children.len();
1750            let mut child_path = layer_path.clone();
1751            child_path.push(child_index);
1752            if let Some(child_layer) = generate_render_layer_recursive(
1753                *child,
1754                ir,
1755                snapshot,
1756                scroll_map,
1757                animation_map,
1758                paint_cache,
1759                boundary_cache,
1760                runtime_dynamic_subtrees,
1761                miss_count,
1762                hit_count,
1763                scene_cache_allowed,
1764                visited,
1765                bindings,
1766                child_path,
1767            ) {
1768                layer.children.push(RenderNode::Layer(child_layer));
1769            }
1770        }
1771    }
1772
1773    if let Some(scrollbar) = build_scrollbar_paint(ir, node_id, snapshot, scroll_map) {
1774        let mut scrollbar_path = layer_path.clone();
1775        scrollbar_path.push(layer.children.len());
1776        layer.children.push(RenderNode::Paint(scrollbar));
1777        bindings.scrollbar.push(ScrollbarBinding {
1778            node_path: scrollbar_path,
1779            node_id,
1780        });
1781    }
1782
1783    if can_use_boundary_cache {
1784        boundary_cache.insert(
1785            node_id,
1786            BoundaryCacheEntry {
1787                hash: scene_cache_key,
1788                layer: layer.clone(),
1789            },
1790        );
1791    }
1792
1793    Some(layer)
1794}
1795
1796fn push_video_surface(
1797    video_surfaces: &mut Vec<VideoSurfaceFrame>,
1798    widget_id: WidgetId,
1799    rect: LayoutRect,
1800    video_map: &VideoStateMap,
1801) {
1802    if let Some(state) = video_map.states.get(&widget_id) {
1803        let surface_id = state.surface_id.unwrap_or(0);
1804        video_surfaces.push(VideoSurfaceFrame {
1805            widget_id,
1806            surface_id,
1807            rect,
1808        });
1809    }
1810}
1811
1812fn push_web_surface(
1813    web_surfaces: &mut Vec<WebSurfaceFrame>,
1814    widget_id: WidgetId,
1815    rect: LayoutRect,
1816    web_map: &WebStateMap,
1817) {
1818    if let Some(state) = web_map.states.get(&widget_id) {
1819        if !state.url.trim().is_empty() {
1820            web_surfaces.push(WebSurfaceFrame {
1821                widget_id,
1822                url: state.url.clone(),
1823                user_agent: state.user_agent.clone(),
1824                rect,
1825            });
1826        }
1827    }
1828}
1829
1830fn collect_video_surfaces(
1831    node_id: WidgetId,
1832    ir: &CoreIR,
1833    snapshot: &LayoutSnapshot,
1834    video_map: &VideoStateMap,
1835    web_map: &WebStateMap,
1836    scroll_map: &ScrollStateMap,
1837    accumulated_offset: LayoutPoint,
1838    video_surfaces: &mut Vec<VideoSurfaceFrame>,
1839    web_surfaces: &mut Vec<WebSurfaceFrame>,
1840    scene_3d_surfaces: &mut Vec<(WidgetId, LayoutRect, Vec<u8>)>,
1841) {
1842    let mut visited = HashSet::new();
1843    collect_video_surfaces_with_visited(
1844        node_id,
1845        ir,
1846        snapshot,
1847        video_map,
1848        web_map,
1849        scroll_map,
1850        accumulated_offset,
1851        video_surfaces,
1852        web_surfaces,
1853        scene_3d_surfaces,
1854        &mut visited,
1855    );
1856}
1857
1858fn collect_video_surfaces_with_visited(
1859    node_id: WidgetId,
1860    ir: &CoreIR,
1861    snapshot: &LayoutSnapshot,
1862    video_map: &VideoStateMap,
1863    web_map: &WebStateMap,
1864    scroll_map: &ScrollStateMap,
1865    accumulated_offset: LayoutPoint,
1866    video_surfaces: &mut Vec<VideoSurfaceFrame>,
1867    web_surfaces: &mut Vec<WebSurfaceFrame>,
1868    scene_3d_surfaces: &mut Vec<(WidgetId, LayoutRect, Vec<u8>)>,
1869    visited: &mut HashSet<WidgetId>,
1870) {
1871    if !visited.insert(node_id) {
1872        return;
1873    }
1874    if let (Some(node), Some(geom)) = (ir.nodes.get(&node_id), snapshot.nodes.get(&node_id)) {
1875        let mut child_offset = accumulated_offset;
1876        if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
1877            let offset = scroll_map.get_offset(node_id);
1878            child_offset = match direction {
1879                fission_ir::FlexDirection::Row => {
1880                    LayoutPoint::new(accumulated_offset.x - offset, accumulated_offset.y)
1881                }
1882                fission_ir::FlexDirection::Column => {
1883                    LayoutPoint::new(accumulated_offset.x, accumulated_offset.y - offset)
1884                }
1885            };
1886        }
1887
1888        if let Op::Layout(LayoutOp::Embed {
1889            kind: EmbedKind::Video,
1890            widget_id,
1891            ..
1892        }) = &node.op
1893        {
1894            let translated_rect = translate_rect(geom.rect, accumulated_offset);
1895            push_video_surface(video_surfaces, *widget_id, translated_rect, video_map);
1896        } else if let Op::Layout(LayoutOp::Embed {
1897            kind: EmbedKind::Web,
1898            widget_id,
1899            ..
1900        }) = &node.op
1901        {
1902            let translated_rect = translate_rect(geom.rect, accumulated_offset);
1903            push_web_surface(web_surfaces, *widget_id, translated_rect, web_map);
1904        } else if let Op::Layout(LayoutOp::Embed {
1905            kind: EmbedKind::Custom(payload),
1906            widget_id,
1907            ..
1908        }) = &node.op
1909        {
1910            let translated_rect = translate_rect(geom.rect, accumulated_offset);
1911            scene_3d_surfaces.push((*widget_id, translated_rect, payload.clone()));
1912        }
1913
1914        for child in &node.children {
1915            collect_video_surfaces_with_visited(
1916                *child,
1917                ir,
1918                snapshot,
1919                video_map,
1920                web_map,
1921                scroll_map,
1922                child_offset,
1923                video_surfaces,
1924                web_surfaces,
1925                scene_3d_surfaces,
1926                visited,
1927            );
1928        }
1929    }
1930}
1931
1932fn local_paint_hash(node: &fission_ir::CoreNode) -> u64 {
1933    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1934    node.op.hash(&mut hasher);
1935    hasher.finish()
1936}
1937
1938fn boundary_hash(node: &fission_ir::CoreNode, rect: LayoutRect) -> u64 {
1939    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1940    node.hash.hash(&mut hasher);
1941    rect.origin.x.to_bits().hash(&mut hasher);
1942    rect.origin.y.to_bits().hash(&mut hasher);
1943    rect.size.width.to_bits().hash(&mut hasher);
1944    rect.size.height.to_bits().hash(&mut hasher);
1945    hasher.finish()
1946}
1947
1948fn build_local_paint_list(
1949    ir: &CoreIR,
1950    node_id: WidgetId,
1951    node: &fission_ir::CoreNode,
1952    rect: LayoutRect,
1953) -> Option<DisplayList> {
1954    let mut list = DisplayList::new(rect);
1955    match &node.op {
1956        Op::Paint(fission_ir::PaintOp::DrawRect {
1957            fill,
1958            stroke,
1959            corner_radius,
1960            shadow,
1961        }) => {
1962            list.push(DisplayOp::DrawRect {
1963                rect,
1964                fill: fill.as_ref().map(map_fill),
1965                stroke: stroke.as_ref().map(map_stroke),
1966                corner_radius: *corner_radius,
1967                shadow: shadow.as_ref().map(|s| BoxShadow {
1968                    color: RenderColor {
1969                        r: s.color.r,
1970                        g: s.color.g,
1971                        b: s.color.b,
1972                        a: s.color.a,
1973                    },
1974                    blur_radius: s.blur_radius,
1975                    offset: s.offset,
1976                }),
1977                bounds: rect,
1978                node_id: Some(node_id),
1979            });
1980        }
1981        Op::Paint(fission_ir::PaintOp::DrawText {
1982            text,
1983            size,
1984            color,
1985            underline,
1986            wrap,
1987            caret_index,
1988            caret_color,
1989            caret_width,
1990            caret_height,
1991            caret_radius,
1992            paragraph_style,
1993        }) => {
1994            list.push(DisplayOp::DrawText {
1995                text: text.clone(),
1996                position: rect.origin,
1997                size: *size,
1998                color: RenderColor {
1999                    r: color.r,
2000                    g: color.g,
2001                    b: color.b,
2002                    a: color.a,
2003                },
2004                bounds: rect,
2005                node_id: Some(node_id),
2006                underline: *underline,
2007                wrap: *wrap,
2008                caret_index: *caret_index,
2009                caret_color: caret_color.map(|color| RenderColor {
2010                    r: color.r,
2011                    g: color.g,
2012                    b: color.b,
2013                    a: color.a,
2014                }),
2015                caret_width: *caret_width,
2016                caret_height: *caret_height,
2017                caret_radius: *caret_radius,
2018                paragraph_style: *paragraph_style,
2019            });
2020        }
2021        Op::Paint(fission_ir::PaintOp::DrawRichText {
2022            runs,
2023            wrap,
2024            caret_index,
2025            caret_color,
2026            caret_width,
2027            caret_height,
2028            caret_radius,
2029            paragraph_style,
2030        }) => {
2031            let annotations = ir
2032                .custom_render_objects
2033                .get(&node_id)
2034                .and_then(|sidecar| {
2035                    sidecar.downcast_ref::<Vec<fission_ir::op::RichTextAnnotation>>()
2036                })
2037                .cloned()
2038                .unwrap_or_default();
2039            let render_runs = runs
2040                .iter()
2041                .map(|r| fission_render::TextRun {
2042                    text: r.text.clone(),
2043                    style: fission_render::TextStyle {
2044                        font_size: r.style.font_size,
2045                        color: RenderColor {
2046                            r: r.style.color.r,
2047                            g: r.style.color.g,
2048                            b: r.style.color.b,
2049                            a: r.style.color.a,
2050                        },
2051                        underline: r.style.underline,
2052                        font_family: r.style.font_family.clone(),
2053                        locale: r.style.locale.clone(),
2054                        font_weight: r.style.font_weight,
2055                        font_style: r.style.font_style,
2056                        line_height: r.style.line_height,
2057                        letter_spacing: r.style.letter_spacing,
2058                        background_color: r.style.background_color.map(|c| RenderColor {
2059                            r: c.r,
2060                            g: c.g,
2061                            b: c.b,
2062                            a: c.a,
2063                        }),
2064                    },
2065                })
2066                .collect();
2067
2068            list.push(DisplayOp::DrawRichText {
2069                runs: render_runs,
2070                position: rect.origin,
2071                bounds: rect,
2072                node_id: Some(node_id),
2073                wrap: *wrap,
2074                caret_index: *caret_index,
2075                caret_color: caret_color.map(|color| RenderColor {
2076                    r: color.r,
2077                    g: color.g,
2078                    b: color.b,
2079                    a: color.a,
2080                }),
2081                caret_width: *caret_width,
2082                caret_height: *caret_height,
2083                caret_radius: *caret_radius,
2084                paragraph_style: *paragraph_style,
2085                annotations,
2086            });
2087        }
2088        Op::Paint(fission_ir::PaintOp::DrawImage {
2089            request,
2090            fit,
2091            alignment,
2092        }) => {
2093            list.push(DisplayOp::DrawImage {
2094                rect,
2095                request: request.clone(),
2096                fit: match fit {
2097                    fission_ir::op::ImageFit::Contain => fission_render::ImageFit::Contain,
2098                    fission_ir::op::ImageFit::Cover => fission_render::ImageFit::Cover,
2099                    fission_ir::op::ImageFit::Fill => fission_render::ImageFit::Fill,
2100                    fission_ir::op::ImageFit::None => fission_render::ImageFit::None,
2101                },
2102                alignment: *alignment,
2103                bounds: rect,
2104                node_id: Some(node_id),
2105            });
2106        }
2107        Op::Paint(fission_ir::PaintOp::DrawPath { path, fill, stroke }) => {
2108            list.push(DisplayOp::DrawPath {
2109                path: path.clone(),
2110                fill: fill.as_ref().map(map_fill),
2111                stroke: stroke.as_ref().map(map_stroke),
2112                bounds: rect,
2113                node_id: Some(node_id),
2114            });
2115        }
2116        Op::Paint(fission_ir::PaintOp::DrawSvg {
2117            content,
2118            fill,
2119            stroke,
2120        }) => {
2121            list.push(DisplayOp::DrawSvg {
2122                content: content.clone(),
2123                fill: fill.as_ref().map(map_fill),
2124                stroke: stroke.as_ref().map(map_stroke),
2125                bounds: rect,
2126                node_id: Some(node_id),
2127            });
2128        }
2129        Op::Layout(LayoutOp::Embed {
2130            kind, widget_id, ..
2131        }) => {
2132            list.push(DisplayOp::DrawSurface {
2133                rect,
2134                surface_id: embed_surface_id(kind, *widget_id),
2135                position: 0,
2136                bounds: rect,
2137                node_id: Some(node_id),
2138            });
2139        }
2140        _ => {}
2141    }
2142    if list.ops.is_empty() {
2143        None
2144    } else {
2145        Some(list)
2146    }
2147}
2148
2149fn build_scrollbar_paint(
2150    ir: &CoreIR,
2151    node_id: WidgetId,
2152    snapshot: &LayoutSnapshot,
2153    scroll_map: &ScrollStateMap,
2154) -> Option<DisplayList> {
2155    let geometry = scrollbar_geometry_for_node(ir, snapshot, scroll_map, node_id)?;
2156    let rail_fill = Some(Fill::Solid(RenderColor {
2157        r: 160,
2158        g: 168,
2159        b: 180,
2160        a: 80,
2161    }));
2162    let thumb_fill = Some(Fill::Solid(RenderColor {
2163        r: 82,
2164        g: 91,
2165        b: 108,
2166        a: 190,
2167    }));
2168    let mut list = DisplayList::new(geometry.rail_rect);
2169    let corner_radius = fission_core::scrollbar::SCROLLBAR_THICKNESS / 2.0;
2170
2171    list.push(DisplayOp::DrawRect {
2172        rect: geometry.rail_rect,
2173        fill: rail_fill,
2174        stroke: None,
2175        corner_radius,
2176        shadow: None,
2177        bounds: geometry.rail_rect,
2178        node_id: Some(node_id),
2179    });
2180    list.push(DisplayOp::DrawRect {
2181        rect: geometry.thumb_rect,
2182        fill: thumb_fill,
2183        stroke: None,
2184        corner_radius,
2185        shadow: None,
2186        bounds: geometry.thumb_rect,
2187        node_id: Some(node_id),
2188    });
2189
2190    Some(list)
2191}
2192
2193fn resolve_composite_scalar(
2194    scalar: Option<&fission_ir::CompositeScalar>,
2195    animation_map: &AnimationStateMap,
2196    property: AnimationPropertyId,
2197) -> Option<f32> {
2198    let scalar = scalar?;
2199    Some(resolve_scalar_value(scalar, animation_map, property))
2200}
2201
2202fn resolve_scalar_value(
2203    scalar: &fission_ir::CompositeScalar,
2204    animation_map: &AnimationStateMap,
2205    property: AnimationPropertyId,
2206) -> f32 {
2207    scalar
2208        .animation_target
2209        .and_then(|target| animation_map.values.get(&(target, property)).copied())
2210        .unwrap_or(scalar.base)
2211}
2212
2213fn composite_transform_matrix(
2214    rect: LayoutRect,
2215    translate_x: f32,
2216    translate_y: f32,
2217    scale: f32,
2218    rotation: f32,
2219) -> [f32; 16] {
2220    let center_x = rect.origin.x + rect.size.width * 0.5;
2221    let center_y = rect.origin.y + rect.size.height * 0.5;
2222
2223    let to_center = translation_matrix(center_x, center_y);
2224    let from_center = translation_matrix(-center_x, -center_y);
2225    let scale_matrix = scale_matrix(scale);
2226    let rotation_matrix = rotation_z_matrix(rotation);
2227    let animated_translate = translation_matrix(translate_x, translate_y);
2228
2229    multiply_matrix(
2230        animated_translate,
2231        multiply_matrix(
2232            to_center,
2233            multiply_matrix(rotation_matrix, multiply_matrix(scale_matrix, from_center)),
2234        ),
2235    )
2236}
2237
2238fn translation_matrix(tx: f32, ty: f32) -> [f32; 16] {
2239    [
2240        1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, tx, ty, 0.0, 1.0,
2241    ]
2242}
2243
2244fn scale_matrix(scale: f32) -> [f32; 16] {
2245    [
2246        scale, 0.0, 0.0, 0.0, 0.0, scale, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
2247    ]
2248}
2249
2250fn rotation_z_matrix(radians: f32) -> [f32; 16] {
2251    let sin = radians.sin();
2252    let cos = radians.cos();
2253    [
2254        cos, sin, 0.0, 0.0, -sin, cos, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
2255    ]
2256}
2257
2258fn multiply_matrix(a: [f32; 16], b: [f32; 16]) -> [f32; 16] {
2259    let mut out = [0.0; 16];
2260    for row in 0..4 {
2261        for col in 0..4 {
2262            let mut sum = 0.0;
2263            for k in 0..4 {
2264                sum += a[row * 4 + k] * b[k * 4 + col];
2265            }
2266            out[row * 4 + col] = sum;
2267        }
2268    }
2269    out
2270}
2271
2272fn is_identity_matrix(matrix: &[f32; 16]) -> bool {
2273    const IDENTITY: [f32; 16] = [
2274        1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
2275    ];
2276    matrix
2277        .iter()
2278        .zip(IDENTITY.iter())
2279        .all(|(lhs, rhs)| (*lhs - *rhs).abs() <= 0.000_1)
2280}
2281
2282#[cfg(test)]
2283fn scroll_offsets_changed(prev: &HashMap<WidgetId, u32>, scroll_map: &ScrollStateMap) -> bool {
2284    if prev.len() != scroll_map.offsets.len() {
2285        return true;
2286    }
2287
2288    scroll_map
2289        .offsets
2290        .iter()
2291        .any(|(id, offset)| prev.get(id).copied() != Some(offset.to_bits()))
2292}
2293
2294impl SnapshotProvider for Pipeline {
2295    fn snapshot(&self, kind: SnapshotKind) -> Option<SnapshotBlob> {
2296        match kind {
2297            SnapshotKind::Layout => self.last_snapshot.as_ref().and_then(|snap| {
2298                serde_json::to_string_pretty(snap)
2299                    .ok()
2300                    .map(|json| SnapshotBlob { kind, json })
2301            }),
2302        }
2303    }
2304}
2305
2306fn map_fill(f: &fission_ir::op::Fill) -> Fill {
2307    match f {
2308        fission_ir::op::Fill::Solid(c) => Fill::Solid(RenderColor {
2309            r: c.r,
2310            g: c.g,
2311            b: c.b,
2312            a: c.a,
2313        }),
2314        fission_ir::op::Fill::LinearGradient { start, end, stops } => Fill::LinearGradient {
2315            start: *start,
2316            end: *end,
2317            stops: stops
2318                .iter()
2319                .map(|(o, c)| {
2320                    (
2321                        *o,
2322                        RenderColor {
2323                            r: c.r,
2324                            g: c.g,
2325                            b: c.b,
2326                            a: c.a,
2327                        },
2328                    )
2329                })
2330                .collect(),
2331        },
2332        fission_ir::op::Fill::RadialGradient {
2333            center,
2334            radius,
2335            stops,
2336        } => Fill::RadialGradient {
2337            center: *center,
2338            radius: *radius,
2339            stops: stops
2340                .iter()
2341                .map(|(o, c)| {
2342                    (
2343                        *o,
2344                        RenderColor {
2345                            r: c.r,
2346                            g: c.g,
2347                            b: c.b,
2348                            a: c.a,
2349                        },
2350                    )
2351                })
2352                .collect(),
2353        },
2354    }
2355}
2356
2357fn map_stroke(s: &fission_ir::op::Stroke) -> Stroke {
2358    Stroke {
2359        fill: map_fill(&s.fill),
2360        width: s.width,
2361        dash_array: s.dash_array.clone(),
2362        line_cap: match s.line_cap {
2363            fission_ir::op::LineCap::Butt => fission_render::LineCap::Butt,
2364            fission_ir::op::LineCap::Round => fission_render::LineCap::Round,
2365            fission_ir::op::LineCap::Square => fission_render::LineCap::Square,
2366        },
2367        line_join: match s.line_join {
2368            fission_ir::op::LineJoin::Miter => fission_render::LineJoin::Miter,
2369            fission_ir::op::LineJoin::Round => fission_render::LineJoin::Round,
2370            fission_ir::op::LineJoin::Bevel => fission_render::LineJoin::Bevel,
2371        },
2372    }
2373}
2374
2375fn translate_rect(rect: LayoutRect, offset: LayoutPoint) -> LayoutRect {
2376    LayoutRect {
2377        origin: LayoutPoint::new(rect.origin.x + offset.x, rect.origin.y + offset.y),
2378        size: rect.size,
2379    }
2380}
2381
2382#[cfg(test)]
2383mod tests {
2384    use super::{build_local_paint_list, scroll_offsets_changed, InvalidationSet, Pipeline};
2385    use fission_core::env::Env;
2386    use fission_core::registry::AnimationPropertyId;
2387    use fission_core::ScrollStateMap;
2388    use fission_ir::op::{
2389        Color, Fill, ImageAlignment, ImageFit, ImageRequest, ImageSource, RichTextAnnotation,
2390        TextRun, TextStyle,
2391    };
2392    use fission_ir::semantics::ActionTrigger;
2393    use fission_ir::{
2394        ActionEntry, CompositeScalar, CompositeStyle, CoreIR, EmbedKind, LayoutOp, Op, PaintOp,
2395        WidgetId,
2396    };
2397    use fission_layout::{LayoutEngine, LayoutRect, LayoutSize};
2398    use fission_render::{DisplayOp, RenderScene, Renderer};
2399    use std::collections::HashMap;
2400    use std::sync::Arc;
2401
2402    struct NullRenderer;
2403
2404    impl Renderer for NullRenderer {
2405        fn render_scene(&mut self, _scene: &RenderScene) -> anyhow::Result<()> {
2406            Ok(())
2407        }
2408    }
2409
2410    fn two_child_layout_ir(second_width: f32) -> CoreIR {
2411        let root = WidgetId::derived(50, &[0]);
2412        let first = WidgetId::derived(50, &[1]);
2413        let second = WidgetId::derived(50, &[2]);
2414        let mut ir = CoreIR::new();
2415        ir.add_node(
2416            first,
2417            Op::Layout(LayoutOp::Box {
2418                width: Some(40.0),
2419                height: Some(20.0),
2420                min_width: None,
2421                max_width: None,
2422                min_height: None,
2423                max_height: None,
2424                padding: [0.0; 4],
2425                flex_grow: 0.0,
2426                flex_shrink: 1.0,
2427                aspect_ratio: None,
2428            }),
2429            vec![],
2430        );
2431        ir.add_node(
2432            second,
2433            Op::Layout(LayoutOp::Box {
2434                width: Some(second_width),
2435                height: Some(20.0),
2436                min_width: None,
2437                max_width: None,
2438                min_height: None,
2439                max_height: None,
2440                padding: [0.0; 4],
2441                flex_grow: 0.0,
2442                flex_shrink: 1.0,
2443                aspect_ratio: None,
2444            }),
2445            vec![],
2446        );
2447        ir.add_node(
2448            root,
2449            Op::Layout(LayoutOp::Flex {
2450                direction: fission_ir::FlexDirection::Column,
2451                wrap: fission_ir::op::FlexWrap::NoWrap,
2452                flex_grow: 0.0,
2453                flex_shrink: 1.0,
2454                padding: [0.0; 4],
2455                gap: Some(4.0),
2456                align_items: fission_ir::op::AlignItems::Start,
2457                justify_content: fission_ir::op::JustifyContent::Start,
2458            }),
2459            vec![first, second],
2460        );
2461        ir.set_root(root);
2462        ir
2463    }
2464
2465    #[test]
2466    fn unchanged_scroll_offsets_do_not_invalidate_cache() {
2467        let id = WidgetId::derived(1, &[0]);
2468        let mut prev = HashMap::new();
2469        prev.insert(id, 12.5f32.to_bits());
2470        let mut scroll = ScrollStateMap::default();
2471        scroll.set_offset(id, 12.5);
2472        assert!(!scroll_offsets_changed(&prev, &scroll));
2473    }
2474
2475    #[test]
2476    fn changed_scroll_offsets_invalidate_cache() {
2477        let id = WidgetId::derived(2, &[0]);
2478        let mut prev = HashMap::new();
2479        prev.insert(id, 0.0f32.to_bits());
2480        let mut scroll = ScrollStateMap::default();
2481        scroll.set_offset(id, 4.0);
2482        assert!(scroll_offsets_changed(&prev, &scroll));
2483    }
2484
2485    #[test]
2486    fn incremental_layout_keeps_rebuild_telemetry_honest() {
2487        let mut pipeline = Pipeline::new();
2488        let mut layout_engine = LayoutEngine::new();
2489        let scroll = ScrollStateMap::default();
2490
2491        pipeline.replace_ir(two_child_layout_ir(60.0), &Env::default());
2492        let first_pass = pipeline
2493            .ensure_layout(
2494                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2495                &mut layout_engine,
2496                &scroll,
2497            )
2498            .expect("initial layout");
2499        assert_eq!(first_pass, pipeline.layout_input_nodes.len());
2500        assert_eq!(pipeline.layout_full_rebuild_count, 1);
2501
2502        pipeline.replace_ir(two_child_layout_ir(90.0), &Env::default());
2503        let second_pass = pipeline
2504            .ensure_layout(
2505                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2506                &mut layout_engine,
2507                &scroll,
2508            )
2509            .expect("incremental layout");
2510
2511        assert_eq!(second_pass, 1);
2512        assert_eq!(pipeline.layout_full_rebuild_count, 1);
2513        assert!(pipeline.pending_layout_dirty_nodes.is_empty());
2514    }
2515
2516    #[test]
2517    fn rich_text_annotations_flow_into_display_ops() {
2518        let node_id = WidgetId::derived(9, &[0]);
2519        let mut ir = CoreIR::new();
2520        ir.add_node(
2521            node_id,
2522            Op::Paint(PaintOp::DrawRichText {
2523                runs: vec![TextRun {
2524                    text: "docs".into(),
2525                    style: TextStyle {
2526                        font_size: 14.0,
2527                        color: Color::BLACK,
2528                        underline: false,
2529                        font_family: None,
2530                        locale: None,
2531                        font_weight: 400,
2532                        font_style: fission_ir::op::FontStyle::Normal,
2533                        line_height: None,
2534                        letter_spacing: 0.0,
2535                        background_color: None,
2536                    },
2537                }],
2538                wrap: true,
2539                caret_index: None,
2540                caret_color: None,
2541                caret_width: None,
2542                caret_height: None,
2543                caret_radius: None,
2544                paragraph_style: None,
2545            }),
2546            vec![],
2547        );
2548        ir.custom_render_objects.insert(
2549            node_id,
2550            Arc::new(vec![RichTextAnnotation {
2551                range: 0..4,
2552                semantics_label: Some("Documentation".into()),
2553                semantics_identifier: Some("docs-link".into()),
2554                spell_out: Some(true),
2555                mouse_cursor: Some(fission_ir::op::MouseCursor::Pointer),
2556                actions: vec![ActionEntry {
2557                    trigger: ActionTrigger::Default,
2558                    action_id: 7,
2559                    payload_data: Some(vec![1, 2, 3]),
2560                }],
2561            }]),
2562        );
2563
2564        let node = ir.nodes.get(&node_id).expect("paint node");
2565        let list =
2566            build_local_paint_list(&ir, node_id, node, LayoutRect::new(0.0, 0.0, 160.0, 40.0))
2567                .expect("display list");
2568        match list.ops.first() {
2569            Some(DisplayOp::DrawRichText { annotations, .. }) => {
2570                assert_eq!(annotations.len(), 1);
2571                assert_eq!(annotations[0].range, 0..4);
2572                assert_eq!(
2573                    annotations[0].semantics_identifier.as_deref(),
2574                    Some("docs-link")
2575                );
2576            }
2577            other => panic!("expected rich text op, got {other:?}"),
2578        }
2579    }
2580
2581    #[test]
2582    fn draw_image_paint_ops_flow_into_display_ops() {
2583        let node_id = WidgetId::derived(12, &[0]);
2584        let request = ImageRequest {
2585            source: ImageSource::Network {
2586                url: "https://example.com/product.webp".into(),
2587                headers: Vec::new(),
2588                cache_policy: Default::default(),
2589            },
2590            cache_width: Some(220),
2591            cache_height: Some(160),
2592            semantic_label: Some("Product thumbnail".into()),
2593            ..Default::default()
2594        };
2595        let mut ir = CoreIR::new();
2596        ir.add_node(
2597            node_id,
2598            Op::Paint(PaintOp::DrawImage {
2599                request: request.clone(),
2600                fit: ImageFit::Cover,
2601                alignment: ImageAlignment::Center,
2602            }),
2603            vec![],
2604        );
2605
2606        let node = ir.nodes.get(&node_id).expect("image node");
2607        let rect = LayoutRect::new(24.0, 32.0, 220.0, 160.0);
2608        let list = build_local_paint_list(&ir, node_id, node, rect).expect("display list");
2609
2610        match list.ops.first() {
2611            Some(DisplayOp::DrawImage {
2612                rect: image_rect,
2613                request: image_request,
2614                fit,
2615                alignment,
2616                bounds,
2617                node_id: Some(image_node_id),
2618            }) => {
2619                assert_eq!(*image_rect, rect);
2620                assert_eq!(image_request, &request);
2621                assert_eq!(*fit, fission_render::ImageFit::Cover);
2622                assert_eq!(*alignment, ImageAlignment::Center);
2623                assert_eq!(*bounds, rect);
2624                assert_eq!(*image_node_id, node_id);
2625            }
2626            other => panic!("expected image display op, got {other:?}"),
2627        }
2628    }
2629
2630    #[test]
2631    fn retained_pipeline_scene_keeps_draw_image_ops() {
2632        let image_id = WidgetId::derived(13, &[0]);
2633        let root_id = WidgetId::derived(13, &[1]);
2634        let request = ImageRequest {
2635            source: ImageSource::Network {
2636                url: "https://example.com/catalog/thumbnail.webp".into(),
2637                headers: Vec::new(),
2638                cache_policy: Default::default(),
2639            },
2640            semantic_label: Some("Catalog thumbnail".into()),
2641            ..Default::default()
2642        };
2643        let mut ir = CoreIR::new();
2644        ir.add_node(
2645            image_id,
2646            Op::Paint(PaintOp::DrawImage {
2647                request: request.clone(),
2648                fit: ImageFit::Cover,
2649                alignment: ImageAlignment::Center,
2650            }),
2651            vec![],
2652        );
2653        ir.add_node(
2654            root_id,
2655            Op::Layout(LayoutOp::Box {
2656                width: Some(220.0),
2657                height: Some(160.0),
2658                min_width: None,
2659                max_width: None,
2660                min_height: None,
2661                max_height: None,
2662                padding: [0.0, 0.0, 0.0, 0.0],
2663                flex_grow: 0.0,
2664                flex_shrink: 0.0,
2665                aspect_ratio: None,
2666            }),
2667            vec![image_id],
2668        );
2669        ir.set_root(root_id);
2670
2671        let mut pipeline = Pipeline::new();
2672        let mut layout_engine = LayoutEngine::new();
2673        let scroll = ScrollStateMap::default();
2674        pipeline.replace_ir(ir, &Env::default());
2675        pipeline
2676            .ensure_layout(
2677                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2678                &mut layout_engine,
2679                &scroll,
2680            )
2681            .unwrap();
2682        pipeline
2683            .prepare_current(
2684                LayoutSize {
2685                    width: 320.0,
2686                    height: 240.0,
2687                },
2688                LayoutSize {
2689                    width: 320.0,
2690                    height: 240.0,
2691                },
2692                false,
2693                &scroll,
2694                &Default::default(),
2695                &Default::default(),
2696                &Default::default(),
2697            )
2698            .unwrap();
2699
2700        let display_list = pipeline.retained_scene().expect("retained scene").flatten();
2701        let image_op = display_list.ops.iter().find_map(|op| match op {
2702            DisplayOp::DrawImage {
2703                rect,
2704                request: image_request,
2705                fit,
2706                alignment,
2707                ..
2708            } => Some((rect, image_request, fit, alignment)),
2709            _ => None,
2710        });
2711
2712        let Some((rect, image_request, fit, alignment)) = image_op else {
2713            panic!("retained scene dropped DrawImage op");
2714        };
2715        assert_eq!(image_request, &request);
2716        assert_eq!(*fit, fission_render::ImageFit::Cover);
2717        assert_eq!(*alignment, ImageAlignment::Center);
2718        assert_eq!(rect.size.width, 220.0);
2719        assert_eq!(rect.size.height, 160.0);
2720    }
2721
2722    #[test]
2723    fn embed_layout_ops_flow_into_surface_display_ops() {
2724        let node_id = WidgetId::derived(14, &[0]);
2725        let widget_id = WidgetId::explicit("embed.surface");
2726        let mut ir = CoreIR::new();
2727        ir.add_node(
2728            node_id,
2729            Op::Layout(LayoutOp::Embed {
2730                kind: EmbedKind::Web,
2731                widget_id,
2732                width: Some(320.0),
2733                height: Some(180.0),
2734            }),
2735            vec![],
2736        );
2737
2738        let node = ir.nodes.get(&node_id).expect("embed node");
2739        let rect = LayoutRect::new(12.0, 24.0, 320.0, 180.0);
2740        let list = build_local_paint_list(&ir, node_id, node, rect).expect("display list");
2741
2742        match list.ops.first() {
2743            Some(DisplayOp::DrawSurface {
2744                rect: surface_rect,
2745                bounds,
2746                node_id: Some(surface_node_id),
2747                ..
2748            }) => {
2749                assert_eq!(*surface_rect, rect);
2750                assert_eq!(*bounds, rect);
2751                assert_eq!(*surface_node_id, node_id);
2752            }
2753            other => panic!("expected surface display op, got {other:?}"),
2754        }
2755    }
2756
2757    #[test]
2758    fn compositor_bound_opacity_animation_is_composite_only() {
2759        let mut ir = CoreIR::new();
2760        let child = WidgetId::derived(10, &[1]);
2761        let root = WidgetId::derived(10, &[0]);
2762        ir.add_node(child, Op::Layout(LayoutOp::AbsoluteFill), vec![]);
2763        ir.add_node_with_composite(
2764            root,
2765            Op::Structural(fission_ir::StructuralOp::Group { stable_hash: 1 }),
2766            CompositeStyle {
2767                opacity: Some(CompositeScalar::new(0.0).animated(WidgetId::explicit("fade"))),
2768                ..Default::default()
2769            },
2770            vec![child],
2771        );
2772        ir.set_root(root);
2773
2774        let mut pipeline = Pipeline::new();
2775        pipeline.replace_ir(ir, &Env::default());
2776        let invalidation = pipeline.classify_animation_updates(&[(
2777            WidgetId::explicit("fade"),
2778            AnimationPropertyId::Opacity,
2779        )]);
2780        assert_eq!(
2781            invalidation,
2782            InvalidationSet {
2783                build: false,
2784                layout: false,
2785                paint: false,
2786                composite: true,
2787            }
2788        );
2789    }
2790
2791    #[test]
2792    fn unbound_custom_animation_requires_build() {
2793        let pipeline = Pipeline::new();
2794        let invalidation = pipeline.classify_animation_updates(&[(
2795            WidgetId::explicit("custom"),
2796            AnimationPropertyId::custom("phase"),
2797        )]);
2798        assert!(invalidation.build);
2799        assert!(invalidation.layout);
2800    }
2801
2802    #[test]
2803    fn compositor_bound_translate_animation_is_composite_only() {
2804        let mut ir = CoreIR::new();
2805        let child = WidgetId::derived(11, &[1]);
2806        let root = WidgetId::derived(11, &[0]);
2807        ir.add_node(
2808            child,
2809            Op::Paint(PaintOp::DrawRect {
2810                fill: Some(Fill::Solid(Color {
2811                    r: 0,
2812                    g: 0,
2813                    b: 0,
2814                    a: 255,
2815                })),
2816                stroke: None,
2817                corner_radius: 0.0,
2818                shadow: None,
2819            }),
2820            vec![],
2821        );
2822        ir.add_node_with_composite(
2823            root,
2824            Op::Layout(LayoutOp::Box {
2825                width: Some(120.0),
2826                height: Some(64.0),
2827                min_width: None,
2828                max_width: None,
2829                min_height: None,
2830                max_height: None,
2831                padding: [0.0, 0.0, 0.0, 0.0],
2832                flex_grow: 0.0,
2833                flex_shrink: 0.0,
2834                aspect_ratio: None,
2835            }),
2836            CompositeStyle {
2837                translate_x: Some(CompositeScalar::new(12.0).animated(WidgetId::explicit("slide"))),
2838                ..Default::default()
2839            },
2840            vec![child],
2841        );
2842        ir.set_root(root);
2843
2844        let mut pipeline = Pipeline::new();
2845        pipeline.replace_ir(ir, &Env::default());
2846        let invalidation = pipeline.classify_animation_updates(&[(
2847            WidgetId::explicit("slide"),
2848            AnimationPropertyId::TranslateX,
2849        )]);
2850        assert_eq!(
2851            invalidation,
2852            InvalidationSet {
2853                build: false,
2854                layout: false,
2855                paint: false,
2856                composite: true,
2857            }
2858        );
2859    }
2860
2861    #[test]
2862    fn dynamic_layer_with_static_contents_gets_content_cache_key() {
2863        let mut ir = CoreIR::new();
2864        let child = WidgetId::derived(12, &[1]);
2865        let root = WidgetId::derived(12, &[0]);
2866        ir.add_node(
2867            child,
2868            Op::Paint(PaintOp::DrawRect {
2869                fill: Some(Fill::Solid(Color {
2870                    r: 20,
2871                    g: 40,
2872                    b: 60,
2873                    a: 255,
2874                })),
2875                stroke: None,
2876                corner_radius: 8.0,
2877                shadow: None,
2878            }),
2879            vec![],
2880        );
2881        ir.add_node_with_composite(
2882            root,
2883            Op::Layout(LayoutOp::Box {
2884                width: Some(160.0),
2885                height: Some(72.0),
2886                min_width: None,
2887                max_width: None,
2888                min_height: None,
2889                max_height: None,
2890                padding: [0.0, 0.0, 0.0, 0.0],
2891                flex_grow: 0.0,
2892                flex_shrink: 0.0,
2893                aspect_ratio: None,
2894            }),
2895            CompositeStyle {
2896                opacity: Some(CompositeScalar::new(0.4).animated(WidgetId::explicit("fade-cache"))),
2897                ..Default::default()
2898            },
2899            vec![child],
2900        );
2901        ir.set_root(root);
2902
2903        let mut pipeline = Pipeline::new();
2904        let mut layout_engine = LayoutEngine::new();
2905        let mut renderer = NullRenderer;
2906        let scroll = ScrollStateMap::default();
2907        pipeline.replace_ir(ir, &Env::default());
2908        pipeline
2909            .ensure_layout(
2910                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2911                &mut layout_engine,
2912                &scroll,
2913            )
2914            .unwrap();
2915        pipeline
2916            .render_current(
2917                LayoutSize {
2918                    width: 320.0,
2919                    height: 240.0,
2920                },
2921                LayoutSize {
2922                    width: 320.0,
2923                    height: 240.0,
2924                },
2925                false,
2926                &mut renderer,
2927                &scroll,
2928                &Default::default(),
2929                &Default::default(),
2930                &Default::default(),
2931            )
2932            .unwrap();
2933
2934        let scene = pipeline
2935            .retained_scene
2936            .as_ref()
2937            .expect("retained scene missing");
2938        let presentation_root = match scene.roots.first() {
2939            Some(fission_render::RenderNode::Layer(layer)) => layer,
2940            _ => panic!("missing presentation layer"),
2941        };
2942        let animated_layer = match presentation_root.children.first() {
2943            Some(fission_render::RenderNode::Layer(layer)) => layer,
2944            _ => panic!("missing animated layer"),
2945        };
2946
2947        assert!(animated_layer.style.cache_key.is_none());
2948        assert!(animated_layer.style.content_cache_key.is_some());
2949    }
2950
2951    #[test]
2952    fn nested_dynamic_descendant_becomes_child_texture_plan() {
2953        let mut ir = CoreIR::new();
2954        let left_paint = WidgetId::derived(13, &[0]);
2955        let animated_paint = WidgetId::derived(13, &[1]);
2956        let animated_wrapper = WidgetId::derived(13, &[2]);
2957        let outer_static = WidgetId::derived(13, &[3]);
2958        let outer_group = WidgetId::derived(13, &[4]);
2959        let root = WidgetId::derived(13, &[5]);
2960
2961        ir.add_node(
2962            left_paint,
2963            Op::Paint(PaintOp::DrawRect {
2964                fill: Some(Fill::Solid(Color {
2965                    r: 10,
2966                    g: 10,
2967                    b: 10,
2968                    a: 255,
2969                })),
2970                stroke: None,
2971                corner_radius: 0.0,
2972                shadow: None,
2973            }),
2974            vec![],
2975        );
2976        ir.add_node(
2977            animated_paint,
2978            Op::Paint(PaintOp::DrawRect {
2979                fill: Some(Fill::Solid(Color {
2980                    r: 200,
2981                    g: 40,
2982                    b: 40,
2983                    a: 255,
2984                })),
2985                stroke: None,
2986                corner_radius: 0.0,
2987                shadow: None,
2988            }),
2989            vec![],
2990        );
2991        ir.add_node_with_composite(
2992            animated_wrapper,
2993            Op::Layout(LayoutOp::Box {
2994                width: Some(96.0),
2995                height: Some(96.0),
2996                min_width: None,
2997                max_width: None,
2998                min_height: None,
2999                max_height: None,
3000                padding: [0.0, 0.0, 0.0, 0.0],
3001                flex_grow: 0.0,
3002                flex_shrink: 0.0,
3003                aspect_ratio: None,
3004            }),
3005            CompositeStyle {
3006                opacity: Some(
3007                    CompositeScalar::new(0.4).animated(WidgetId::explicit("nested-fade")),
3008                ),
3009                ..Default::default()
3010            },
3011            vec![animated_paint],
3012        );
3013        ir.add_node(
3014            outer_static,
3015            Op::Paint(PaintOp::DrawRect {
3016                fill: Some(Fill::Solid(Color {
3017                    r: 20,
3018                    g: 100,
3019                    b: 180,
3020                    a: 255,
3021                })),
3022                stroke: None,
3023                corner_radius: 8.0,
3024                shadow: None,
3025            }),
3026            vec![],
3027        );
3028        ir.add_node(
3029            outer_group,
3030            Op::Layout(LayoutOp::Box {
3031                width: Some(160.0),
3032                height: Some(120.0),
3033                min_width: None,
3034                max_width: None,
3035                min_height: None,
3036                max_height: None,
3037                padding: [0.0, 0.0, 0.0, 0.0],
3038                flex_grow: 0.0,
3039                flex_shrink: 0.0,
3040                aspect_ratio: None,
3041            }),
3042            vec![outer_static, animated_wrapper],
3043        );
3044        ir.add_node(
3045            root,
3046            Op::Layout(LayoutOp::Box {
3047                width: Some(320.0),
3048                height: Some(240.0),
3049                min_width: None,
3050                max_width: None,
3051                min_height: None,
3052                max_height: None,
3053                padding: [0.0, 0.0, 0.0, 0.0],
3054                flex_grow: 0.0,
3055                flex_shrink: 0.0,
3056                aspect_ratio: None,
3057            }),
3058            vec![left_paint, outer_group],
3059        );
3060        ir.set_root(root);
3061
3062        let mut pipeline = Pipeline::new();
3063        let mut layout_engine = LayoutEngine::new();
3064        let scroll = ScrollStateMap::default();
3065        pipeline.replace_ir(ir, &Env::default());
3066        pipeline
3067            .ensure_layout(
3068                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3069                &mut layout_engine,
3070                &scroll,
3071            )
3072            .unwrap();
3073        pipeline
3074            .prepare_current(
3075                LayoutSize {
3076                    width: 320.0,
3077                    height: 240.0,
3078                },
3079                LayoutSize {
3080                    width: 320.0,
3081                    height: 240.0,
3082                },
3083                false,
3084                &scroll,
3085                &Default::default(),
3086                &Default::default(),
3087                &Default::default(),
3088            )
3089            .unwrap();
3090
3091        let plans = pipeline.texture_compositor_plans();
3092        assert!(!plans.is_empty());
3093        assert!(
3094            plans.iter().any(|plan| !plan.children.is_empty()),
3095            "expected at least one retained texture plan to extract nested dynamic descendants"
3096        );
3097    }
3098
3099    #[test]
3100    fn resize_preview_keeps_texture_compositor_root_transform() {
3101        let mut ir = CoreIR::new();
3102        let left = WidgetId::derived(14, &[0]);
3103        let right = WidgetId::derived(14, &[1]);
3104        let root = WidgetId::derived(14, &[2]);
3105
3106        ir.add_node(
3107            left,
3108            Op::Paint(PaintOp::DrawRect {
3109                fill: Some(Fill::Solid(Color {
3110                    r: 80,
3111                    g: 80,
3112                    b: 80,
3113                    a: 255,
3114                })),
3115                stroke: None,
3116                corner_radius: 0.0,
3117                shadow: None,
3118            }),
3119            vec![],
3120        );
3121        ir.add_node(
3122            right,
3123            Op::Paint(PaintOp::DrawRect {
3124                fill: Some(Fill::Solid(Color {
3125                    r: 180,
3126                    g: 180,
3127                    b: 180,
3128                    a: 255,
3129                })),
3130                stroke: None,
3131                corner_radius: 0.0,
3132                shadow: None,
3133            }),
3134            vec![],
3135        );
3136        ir.add_node(
3137            root,
3138            Op::Layout(LayoutOp::Box {
3139                width: Some(300.0),
3140                height: Some(200.0),
3141                min_width: None,
3142                max_width: None,
3143                min_height: None,
3144                max_height: None,
3145                padding: [0.0, 0.0, 0.0, 0.0],
3146                flex_grow: 0.0,
3147                flex_shrink: 0.0,
3148                aspect_ratio: None,
3149            }),
3150            vec![left, right],
3151        );
3152        ir.set_root(root);
3153
3154        let mut pipeline = Pipeline::new();
3155        let mut layout_engine = LayoutEngine::new();
3156        let scroll = ScrollStateMap::default();
3157        pipeline.replace_ir(ir, &Env::default());
3158        pipeline
3159            .ensure_layout(
3160                LayoutRect::new(0.0, 0.0, 300.0, 200.0),
3161                &mut layout_engine,
3162                &scroll,
3163            )
3164            .unwrap();
3165        pipeline
3166            .prepare_current(
3167                LayoutSize {
3168                    width: 540.0,
3169                    height: 360.0,
3170                },
3171                LayoutSize {
3172                    width: 300.0,
3173                    height: 200.0,
3174                },
3175                true,
3176                &scroll,
3177                &Default::default(),
3178                &Default::default(),
3179                &Default::default(),
3180            )
3181            .unwrap();
3182
3183        assert!(pipeline.texture_compositor_root_transform().is_none());
3184        assert!(!pipeline.texture_compositor_plans().is_empty());
3185    }
3186
3187    #[test]
3188    fn scroll_only_layers_patch_retained_transforms_after_offset_changes() {
3189        let mut ir = CoreIR::new();
3190        let content = WidgetId::derived(15, &[0]);
3191        let scroll = WidgetId::derived(15, &[1]);
3192        let root = WidgetId::derived(15, &[2]);
3193
3194        ir.add_node(
3195            content,
3196            Op::Paint(PaintOp::DrawRect {
3197                fill: Some(Fill::Solid(Color {
3198                    r: 120,
3199                    g: 120,
3200                    b: 220,
3201                    a: 255,
3202                })),
3203                stroke: None,
3204                corner_radius: 0.0,
3205                shadow: None,
3206            }),
3207            vec![],
3208        );
3209        ir.add_node(
3210            scroll,
3211            Op::Layout(LayoutOp::Scroll {
3212                direction: fission_ir::FlexDirection::Column,
3213                show_scrollbar: true,
3214                width: Some(320.0),
3215                height: Some(240.0),
3216                min_width: None,
3217                max_width: None,
3218                min_height: None,
3219                max_height: None,
3220                padding: [0.0, 0.0, 0.0, 0.0],
3221                flex_grow: 0.0,
3222                flex_shrink: 0.0,
3223            }),
3224            vec![content],
3225        );
3226        ir.add_node(
3227            root,
3228            Op::Layout(LayoutOp::Box {
3229                width: Some(320.0),
3230                height: Some(240.0),
3231                min_width: None,
3232                max_width: None,
3233                min_height: None,
3234                max_height: None,
3235                padding: [0.0, 0.0, 0.0, 0.0],
3236                flex_grow: 0.0,
3237                flex_shrink: 0.0,
3238                aspect_ratio: None,
3239            }),
3240            vec![scroll],
3241        );
3242        ir.set_root(root);
3243
3244        let mut pipeline = Pipeline::new();
3245        let mut layout_engine = LayoutEngine::new();
3246        let scroll0 = ScrollStateMap::default();
3247        pipeline.replace_ir(ir, &Env::default());
3248        pipeline
3249            .ensure_layout(
3250                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3251                &mut layout_engine,
3252                &scroll0,
3253            )
3254            .unwrap();
3255        pipeline
3256            .prepare_current(
3257                LayoutSize {
3258                    width: 320.0,
3259                    height: 240.0,
3260                },
3261                LayoutSize {
3262                    width: 320.0,
3263                    height: 240.0,
3264                },
3265                false,
3266                &scroll0,
3267                &Default::default(),
3268                &Default::default(),
3269                &Default::default(),
3270            )
3271            .unwrap();
3272
3273        let mut scroll1 = ScrollStateMap::default();
3274        scroll1.set_offset(scroll, 180.0);
3275        pipeline
3276            .prepare_current(
3277                LayoutSize {
3278                    width: 320.0,
3279                    height: 240.0,
3280                },
3281                LayoutSize {
3282                    width: 320.0,
3283                    height: 240.0,
3284                },
3285                false,
3286                &scroll1,
3287                &Default::default(),
3288                &Default::default(),
3289                &Default::default(),
3290            )
3291            .unwrap();
3292
3293        fn find_layer_by_node(
3294            node: &fission_render::RenderNode,
3295            node_id: WidgetId,
3296        ) -> Option<&fission_render::RenderLayer> {
3297            match node {
3298                fission_render::RenderNode::Paint(_) => None,
3299                fission_render::RenderNode::Layer(layer) => {
3300                    if layer.node_id == Some(node_id) {
3301                        return Some(layer);
3302                    }
3303                    for child in &layer.children {
3304                        if let Some(found) = find_layer_by_node(child, node_id) {
3305                            return Some(found);
3306                        }
3307                    }
3308                    None
3309                }
3310            }
3311        }
3312
3313        let scroll_layer = pipeline
3314            .retained_scene()
3315            .and_then(|scene| {
3316                scene
3317                    .roots
3318                    .iter()
3319                    .find_map(|node| find_layer_by_node(node, scroll))
3320            })
3321            .expect("expected a retained scroll layer");
3322        assert!(
3323            scroll_layer.style.transform.is_none(),
3324            "scrollbar chrome must not inherit the content scroll transform"
3325        );
3326        let transform = scroll_layer
3327            .children
3328            .iter()
3329            .find_map(|child| match child {
3330                fission_render::RenderNode::Layer(layer) => layer.style.transform,
3331                fission_render::RenderNode::Paint(_) => None,
3332            })
3333            .expect("scroll content layer should carry a compositor transform");
3334        assert!(
3335            (transform[13] + 180.0).abs() <= 0.01,
3336            "expected retained content transform to patch to -180, got {}",
3337            transform[13]
3338        );
3339    }
3340
3341    #[test]
3342    fn scrollbar_thumb_patches_after_scroll_offset_changes() {
3343        let mut ir = CoreIR::new();
3344        let fill = WidgetId::derived(18, &[0]);
3345        let content = WidgetId::derived(18, &[1]);
3346        let scroll = WidgetId::derived(18, &[2]);
3347        let root = WidgetId::derived(18, &[3]);
3348
3349        ir.add_node(
3350            fill,
3351            Op::Paint(PaintOp::DrawRect {
3352                fill: Some(Fill::Solid(Color {
3353                    r: 120,
3354                    g: 120,
3355                    b: 220,
3356                    a: 255,
3357                })),
3358                stroke: None,
3359                corner_radius: 0.0,
3360                shadow: None,
3361            }),
3362            vec![],
3363        );
3364        ir.add_node(
3365            content,
3366            Op::Layout(LayoutOp::Box {
3367                width: Some(320.0),
3368                height: Some(640.0),
3369                min_width: None,
3370                max_width: None,
3371                min_height: None,
3372                max_height: None,
3373                padding: [0.0, 0.0, 0.0, 0.0],
3374                flex_grow: 0.0,
3375                flex_shrink: 0.0,
3376                aspect_ratio: None,
3377            }),
3378            vec![fill],
3379        );
3380        ir.add_node(
3381            scroll,
3382            Op::Layout(LayoutOp::Scroll {
3383                direction: fission_ir::FlexDirection::Column,
3384                show_scrollbar: true,
3385                width: Some(320.0),
3386                height: Some(240.0),
3387                min_width: None,
3388                max_width: None,
3389                min_height: None,
3390                max_height: None,
3391                padding: [0.0, 0.0, 0.0, 0.0],
3392                flex_grow: 0.0,
3393                flex_shrink: 0.0,
3394            }),
3395            vec![content],
3396        );
3397        ir.add_node(
3398            root,
3399            Op::Layout(LayoutOp::Box {
3400                width: Some(320.0),
3401                height: Some(240.0),
3402                min_width: None,
3403                max_width: None,
3404                min_height: None,
3405                max_height: None,
3406                padding: [0.0, 0.0, 0.0, 0.0],
3407                flex_grow: 0.0,
3408                flex_shrink: 0.0,
3409                aspect_ratio: None,
3410            }),
3411            vec![scroll],
3412        );
3413        ir.set_root(root);
3414
3415        let mut pipeline = Pipeline::new();
3416        let mut layout_engine = LayoutEngine::new();
3417        let scroll0 = ScrollStateMap::default();
3418        pipeline.replace_ir(ir, &Env::default());
3419        pipeline
3420            .ensure_layout(
3421                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3422                &mut layout_engine,
3423                &scroll0,
3424            )
3425            .unwrap();
3426        pipeline
3427            .prepare_current(
3428                LayoutSize::new(320.0, 240.0),
3429                LayoutSize::new(320.0, 240.0),
3430                false,
3431                &scroll0,
3432                &Default::default(),
3433                &Default::default(),
3434                &Default::default(),
3435            )
3436            .unwrap();
3437        let initial_thumb_y = scrollbar_thumb_y(pipeline.retained_scene().unwrap(), scroll)
3438            .expect("initial scrollbar thumb");
3439
3440        let mut scroll1 = ScrollStateMap::default();
3441        scroll1.set_offset(scroll, 200.0);
3442        pipeline
3443            .prepare_current(
3444                LayoutSize::new(320.0, 240.0),
3445                LayoutSize::new(320.0, 240.0),
3446                false,
3447                &scroll1,
3448                &Default::default(),
3449                &Default::default(),
3450                &Default::default(),
3451            )
3452            .unwrap();
3453        let moved_thumb_y = scrollbar_thumb_y(pipeline.retained_scene().unwrap(), scroll)
3454            .expect("moved scrollbar thumb");
3455
3456        assert!(
3457            moved_thumb_y > initial_thumb_y,
3458            "body scroll must patch the retained scrollbar thumb, before={initial_thumb_y}, after={moved_thumb_y}"
3459        );
3460
3461        fn scrollbar_thumb_y(scene: &fission_render::RenderScene, scroll: WidgetId) -> Option<f32> {
3462            fn find(node: &fission_render::RenderNode, scroll: WidgetId) -> Option<f32> {
3463                match node {
3464                    fission_render::RenderNode::Paint(list) => list.ops.iter().find_map(|op| {
3465                        if let fission_render::DisplayOp::DrawRect { rect, node_id, .. } = op {
3466                            if *node_id == Some(scroll)
3467                                && (rect.width() - 6.0).abs() <= 0.01
3468                                && rect.height() < 200.0
3469                            {
3470                                return Some(rect.origin.y);
3471                            }
3472                        }
3473                        None
3474                    }),
3475                    fission_render::RenderNode::Layer(layer) => {
3476                        layer.children.iter().find_map(|child| find(child, scroll))
3477                    }
3478                }
3479            }
3480            scene.roots.iter().find_map(|root| find(root, scroll))
3481        }
3482    }
3483
3484    #[test]
3485    fn overflowing_scroll_nodes_emit_visible_scroll_rails() {
3486        let mut ir = CoreIR::new();
3487        let fill = WidgetId::derived(16, &[0]);
3488        let content = WidgetId::derived(16, &[1]);
3489        let scroll = WidgetId::derived(16, &[2]);
3490        let root = WidgetId::derived(16, &[3]);
3491
3492        ir.add_node(
3493            fill,
3494            Op::Paint(PaintOp::DrawRect {
3495                fill: Some(Fill::Solid(Color {
3496                    r: 80,
3497                    g: 120,
3498                    b: 220,
3499                    a: 255,
3500                })),
3501                stroke: None,
3502                corner_radius: 0.0,
3503                shadow: None,
3504            }),
3505            vec![],
3506        );
3507        ir.add_node(
3508            content,
3509            Op::Layout(LayoutOp::Box {
3510                width: Some(320.0),
3511                height: Some(640.0),
3512                min_width: None,
3513                max_width: None,
3514                min_height: None,
3515                max_height: None,
3516                padding: [0.0, 0.0, 0.0, 0.0],
3517                flex_grow: 0.0,
3518                flex_shrink: 0.0,
3519                aspect_ratio: None,
3520            }),
3521            vec![fill],
3522        );
3523        ir.add_node(
3524            scroll,
3525            Op::Layout(LayoutOp::Scroll {
3526                direction: fission_ir::FlexDirection::Column,
3527                show_scrollbar: true,
3528                width: Some(320.0),
3529                height: Some(240.0),
3530                min_width: None,
3531                max_width: None,
3532                min_height: None,
3533                max_height: None,
3534                padding: [0.0, 0.0, 0.0, 0.0],
3535                flex_grow: 0.0,
3536                flex_shrink: 0.0,
3537            }),
3538            vec![content],
3539        );
3540        ir.add_node(
3541            root,
3542            Op::Layout(LayoutOp::Box {
3543                width: Some(320.0),
3544                height: Some(240.0),
3545                min_width: None,
3546                max_width: None,
3547                min_height: None,
3548                max_height: None,
3549                padding: [0.0, 0.0, 0.0, 0.0],
3550                flex_grow: 0.0,
3551                flex_shrink: 0.0,
3552                aspect_ratio: None,
3553            }),
3554            vec![scroll],
3555        );
3556        ir.set_root(root);
3557
3558        let mut pipeline = Pipeline::new();
3559        let mut layout_engine = LayoutEngine::new();
3560        let scroll_map = ScrollStateMap::default();
3561        pipeline.replace_ir(ir, &Env::default());
3562        pipeline
3563            .ensure_layout(
3564                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3565                &mut layout_engine,
3566                &scroll_map,
3567            )
3568            .unwrap();
3569        pipeline
3570            .prepare_current(
3571                LayoutSize {
3572                    width: 320.0,
3573                    height: 240.0,
3574                },
3575                LayoutSize {
3576                    width: 320.0,
3577                    height: 240.0,
3578                },
3579                false,
3580                &scroll_map,
3581                &Default::default(),
3582                &Default::default(),
3583                &Default::default(),
3584            )
3585            .unwrap();
3586
3587        fn count_scroll_rails(node: &fission_render::RenderNode, scroll: WidgetId) -> usize {
3588            match node {
3589                fission_render::RenderNode::Paint(list) => list
3590                    .ops
3591                    .iter()
3592                    .filter(|op| match op {
3593                        fission_render::DisplayOp::DrawRect { rect, node_id, .. } => {
3594                            *node_id == Some(scroll)
3595                                && (rect.width() - 6.0).abs() <= 0.01
3596                                && rect.height() >= 200.0
3597                        }
3598                        _ => false,
3599                    })
3600                    .count(),
3601                fission_render::RenderNode::Layer(layer) => layer
3602                    .children
3603                    .iter()
3604                    .map(|child| count_scroll_rails(child, scroll))
3605                    .sum(),
3606            }
3607        }
3608
3609        let rail_count: usize = pipeline
3610            .retained_scene()
3611            .expect("retained scene")
3612            .roots
3613            .iter()
3614            .map(|node| count_scroll_rails(node, scroll))
3615            .sum();
3616        assert!(
3617            rail_count > 0,
3618            "expected an overflow rail for the scroll node"
3619        );
3620    }
3621}