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::{Env, VideoStateMap, WebStateMap};
5use fission_core::internal::build_layout_tree;
6use fission_core::internal::downcast_render_object;
7use fission_core::scrollbar::scrollbar_geometry_for_node;
8use fission_core::{LayoutPoint, ScrollStateMap};
9use fission_core::{MotionPropertyId, MotionStateMap};
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, MotionPropertyId)>,
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, MotionPropertyId)],
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: &MotionStateMap,
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: &MotionStateMap,
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            &MotionStateMap::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.motion_target)
602            {
603                self.compositor_animation_keys
604                    .insert((target, MotionPropertyId::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.motion_target)
612            {
613                self.compositor_animation_keys
614                    .insert((target, MotionPropertyId::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.motion_target)
622            {
623                self.compositor_animation_keys
624                    .insert((target, MotionPropertyId::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.motion_target)
632            {
633                self.compositor_animation_keys
634                    .insert((target, MotionPropertyId::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.motion_target)
642            {
643                self.compositor_animation_keys
644                    .insert((target, MotionPropertyId::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.motion_target)
680            .is_some();
681        dynamic |= node
682            .composite
683            .translate_x
684            .as_ref()
685            .and_then(|value| value.motion_target)
686            .is_some();
687        dynamic |= node
688            .composite
689            .translate_y
690            .as_ref()
691            .and_then(|value| value.motion_target)
692            .is_some();
693        dynamic |= node
694            .composite
695            .scale
696            .as_ref()
697            .and_then(|value| value.motion_target)
698            .is_some();
699        dynamic |= node
700            .composite
701            .rotation
702            .as_ref()
703            .and_then(|value| value.motion_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: &MotionStateMap,
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, MotionPropertyId::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            let own_dynamic = layer
1184                .node_id
1185                .map(|id| runtime_dynamic_nodes.contains(&id))
1186                .unwrap_or(false);
1187            let is_scroll_node = layer
1188                .node_id
1189                .map(|id| scroll_nodes.contains(&id))
1190                .unwrap_or(false);
1191
1192            if !force && !own_dynamic && !is_scroll_node {
1193                if let Some(child) = descend_through_plain_wrapper(layer) {
1194                    let mut child_path = node_path.to_vec();
1195                    child_path.push(0);
1196                    return find_nested_texture_plan_candidate(
1197                        child,
1198                        &child_path,
1199                        false,
1200                        runtime_dynamic_nodes,
1201                        scroll_nodes,
1202                        runtime_dynamic_subtrees,
1203                    );
1204                }
1205            }
1206
1207            let subtree_dynamic = render_node_or_subtree_is_dynamic(node, runtime_dynamic_subtrees);
1208            if force
1209                || layer_should_extract_as_plan(layer, subtree_dynamic, own_dynamic, is_scroll_node)
1210            {
1211                Some(TexturePlanCandidate {
1212                    node,
1213                    path: node_path.to_vec(),
1214                })
1215            } else {
1216                for (child_index, child) in layer.children.iter().enumerate() {
1217                    let mut child_path = node_path.to_vec();
1218                    child_path.push(child_index);
1219                    if let Some(candidate) = find_nested_texture_plan_candidate(
1220                        child,
1221                        &child_path,
1222                        false,
1223                        runtime_dynamic_nodes,
1224                        scroll_nodes,
1225                        runtime_dynamic_subtrees,
1226                    ) {
1227                        return Some(candidate);
1228                    }
1229                }
1230                None
1231            }
1232        }
1233    }
1234}
1235
1236fn descend_through_plain_wrapper<'a>(layer: &'a RenderLayer) -> Option<&'a RenderNode> {
1237    let only_child = match layer.children.as_slice() {
1238        [child] => Some(child),
1239        _ => None,
1240    }?;
1241    if layer.style.clip.is_none()
1242        && (layer.style.opacity - 1.0).abs() <= 0.001
1243        && layer.style.transform.is_none()
1244    {
1245        match only_child {
1246            RenderNode::Layer(_) => Some(only_child),
1247            RenderNode::Paint(_) => None,
1248        }
1249    } else {
1250        None
1251    }
1252}
1253
1254fn layer_should_extract_as_plan(
1255    layer: &RenderLayer,
1256    subtree_dynamic: bool,
1257    own_dynamic: bool,
1258    is_scroll_node: bool,
1259) -> bool {
1260    const MIN_PLAN_AREA: f32 = 64.0 * 64.0;
1261    if layer.children.is_empty() {
1262        return false;
1263    }
1264    if is_scroll_node {
1265        return false;
1266    }
1267    if own_dynamic {
1268        return true;
1269    }
1270    if !subtree_dynamic {
1271        return false;
1272    }
1273    let has_style = layer.style.clip.is_some()
1274        || (layer.style.opacity - 1.0).abs() > 0.001
1275        || layer.style.transform.is_some();
1276    let has_local_paint = layer
1277        .children
1278        .iter()
1279        .any(|child| matches!(child, RenderNode::Paint(_)));
1280    let has_multiple_children = layer.children.len() > 1;
1281    (has_style || has_local_paint || has_multiple_children)
1282        && layer.bounds.size.width * layer.bounds.size.height >= MIN_PLAN_AREA
1283}
1284
1285fn localized_scene_for_compositor_children(
1286    children: Vec<RenderNode>,
1287    bounds: LayoutRect,
1288) -> RenderScene {
1289    let local_bounds = LayoutRect::new(0.0, 0.0, bounds.size.width, bounds.size.height);
1290    let mut root = RenderLayer::new(local_bounds);
1291    root.style.transform = Some(translation_matrix(-bounds.origin.x, -bounds.origin.y));
1292    root.children.extend(children);
1293
1294    let mut scene = RenderScene::new(local_bounds);
1295    scene.roots.push(RenderNode::Layer(root));
1296    scene
1297}
1298
1299fn render_node_or_subtree_is_dynamic(
1300    node: &RenderNode,
1301    runtime_dynamic_subtrees: &HashMap<WidgetId, bool>,
1302) -> bool {
1303    match node {
1304        RenderNode::Paint(_) => false,
1305        RenderNode::Layer(layer) => {
1306            layer
1307                .node_id
1308                .and_then(|id| runtime_dynamic_subtrees.get(&id).copied())
1309                .unwrap_or(false)
1310                || layer
1311                    .children
1312                    .iter()
1313                    .any(|child| render_node_or_subtree_is_dynamic(child, runtime_dynamic_subtrees))
1314        }
1315    }
1316}
1317
1318fn texture_plan_key_for_layer(layer: &RenderLayer) -> u64 {
1319    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1320    layer.node_id.hash(&mut hasher);
1321    layer.bounds.size.width.to_bits().hash(&mut hasher);
1322    layer.bounds.size.height.to_bits().hash(&mut hasher);
1323    hash_serde_value(&layer.style.clip, &mut hasher);
1324    hasher.finish()
1325}
1326
1327fn texture_plan_key_for_paint(list: &DisplayList) -> u64 {
1328    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1329    list.bounds.size.width.to_bits().hash(&mut hasher);
1330    list.bounds.size.height.to_bits().hash(&mut hasher);
1331    hash_serde_value(list, &mut hasher);
1332    hasher.finish()
1333}
1334
1335fn scene_cache_key(scene: &RenderScene) -> u64 {
1336    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1337    hash_serde_value(scene, &mut hasher);
1338    hasher.finish()
1339}
1340
1341fn plan_content_key(scene_cache_key: Option<u64>, children: &[CompositorTexturePlan]) -> u64 {
1342    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1343    scene_cache_key.hash(&mut hasher);
1344    for child in children {
1345        child.key.hash(&mut hasher);
1346        child.content_key.hash(&mut hasher);
1347        child.bounds.origin.x.to_bits().hash(&mut hasher);
1348        child.bounds.origin.y.to_bits().hash(&mut hasher);
1349        child.bounds.size.width.to_bits().hash(&mut hasher);
1350        child.bounds.size.height.to_bits().hash(&mut hasher);
1351        child.opacity.to_bits().hash(&mut hasher);
1352        hash_serde_value(&child.transform, &mut hasher);
1353        hash_serde_value(&child.clip, &mut hasher);
1354    }
1355    hasher.finish()
1356}
1357
1358fn patch_texture_compositor_plans(plans: &mut [CompositorTexturePlan], scene: &RenderScene) {
1359    for plan in plans {
1360        patch_texture_compositor_plan(plan, scene);
1361    }
1362}
1363
1364fn patch_texture_compositor_plan(plan: &mut CompositorTexturePlan, scene: &RenderScene) {
1365    for child in &mut plan.children {
1366        patch_texture_compositor_plan(child, scene);
1367    }
1368
1369    if let Some(path) = plan.source_layer_path.as_deref() {
1370        if let Some(layer) = layer_ref_at_path(scene, path) {
1371            plan.bounds = layer.bounds;
1372            plan.opacity = layer.style.opacity;
1373            plan.transform = layer.style.transform;
1374            plan.transform_clip = layer.style.transform_clip;
1375            plan.clip = layer.style.clip.clone();
1376        }
1377    }
1378
1379    plan.content_key = plan_content_key(plan.scene_cache_key, &plan.children);
1380}
1381
1382fn hash_serde_value<T: Serialize, H: Hasher>(value: &T, hasher: &mut H) {
1383    if let Ok(bytes) = bincode::serialize(value) {
1384        bytes.hash(hasher);
1385    }
1386}
1387
1388fn presentation_transform_matrix(
1389    render_viewport_size: LayoutSize,
1390    layout_viewport_size: LayoutSize,
1391    resize_preview: bool,
1392) -> Option<[f32; 16]> {
1393    if !resize_preview
1394        || render_viewport_size.width <= 0.0
1395        || render_viewport_size.height <= 0.0
1396        || layout_viewport_size.width <= 0.0
1397        || layout_viewport_size.height <= 0.0
1398    {
1399        return None;
1400    }
1401
1402    // Do not non-uniformly scale the retained UI during live resize.
1403    // Text-heavy surfaces look visibly distorted; we keep the last committed
1404    // layout anchored in place and rely on throttled relayouts instead.
1405    None
1406}
1407
1408fn compose_dynamic_layer_transform(
1409    binding: &TransformBinding,
1410    scroll_map: &ScrollStateMap,
1411    animation_map: &MotionStateMap,
1412) -> Option<[f32; 16]> {
1413    let mut matrix: Option<[f32; 16]> = None;
1414
1415    if let Some(scroll) = &binding.scroll {
1416        let offset = scroll_map.get_offset(scroll.node_id);
1417        let scroll_matrix = match scroll.direction {
1418            FlexDirection::Row => translation_matrix(-offset, 0.0),
1419            FlexDirection::Column => translation_matrix(0.0, -offset),
1420        };
1421        matrix = append_transform(matrix, scroll_matrix);
1422    }
1423
1424    if let Some(layout_transform) = binding.layout_transform {
1425        matrix = append_transform(matrix, layout_transform);
1426    }
1427
1428    let translate_x = binding
1429        .translate_x
1430        .as_ref()
1431        .map(|scalar| resolve_scalar_value(scalar, animation_map, MotionPropertyId::TranslateX))
1432        .unwrap_or(0.0);
1433    let translate_y = binding
1434        .translate_y
1435        .as_ref()
1436        .map(|scalar| resolve_scalar_value(scalar, animation_map, MotionPropertyId::TranslateY))
1437        .unwrap_or(0.0);
1438    let scale = binding
1439        .scale
1440        .as_ref()
1441        .map(|scalar| resolve_scalar_value(scalar, animation_map, MotionPropertyId::Scale))
1442        .unwrap_or(1.0);
1443    let rotation = binding
1444        .rotation
1445        .as_ref()
1446        .map(|scalar| resolve_scalar_value(scalar, animation_map, MotionPropertyId::Rotation))
1447        .unwrap_or(0.0);
1448
1449    let has_composite_transform = translate_x.abs() > 0.001
1450        || translate_y.abs() > 0.001
1451        || (scale - 1.0).abs() > 0.001
1452        || rotation.abs() > 0.001;
1453    if has_composite_transform {
1454        matrix = append_transform(
1455            matrix,
1456            composite_transform_matrix(binding.rect, translate_x, translate_y, scale, rotation),
1457        );
1458    }
1459
1460    matrix.filter(|value| !is_identity_matrix(value))
1461}
1462
1463fn append_transform(current: Option<[f32; 16]>, next: [f32; 16]) -> Option<[f32; 16]> {
1464    Some(match current {
1465        Some(existing) => multiply_matrix(existing, next),
1466        None => next,
1467    })
1468}
1469
1470fn generate_render_layer_recursive(
1471    node_id: WidgetId,
1472    ir: &CoreIR,
1473    snapshot: &LayoutSnapshot,
1474    scroll_map: &ScrollStateMap,
1475    animation_map: &MotionStateMap,
1476    paint_cache: &mut HashMap<WidgetId, (u64, DisplayList)>,
1477    boundary_cache: &mut HashMap<WidgetId, BoundaryCacheEntry>,
1478    runtime_dynamic_subtrees: &HashMap<WidgetId, bool>,
1479    miss_count: &mut usize,
1480    hit_count: &mut usize,
1481    scene_cache_allowed: bool,
1482    visited: &mut HashSet<WidgetId>,
1483    bindings: &mut RetainedDynamicOps,
1484    layer_path: Vec<usize>,
1485) -> Option<RenderLayer> {
1486    if !visited.insert(node_id) {
1487        return None;
1488    }
1489
1490    let (Some(node), Some(geom)) = (ir.nodes.get(&node_id), snapshot.nodes.get(&node_id)) else {
1491        return None;
1492    };
1493
1494    let rect = geom.rect;
1495    let can_use_boundary_cache = !runtime_dynamic_subtrees
1496        .get(&node_id)
1497        .copied()
1498        .unwrap_or(false);
1499
1500    let scene_cache_key = boundary_hash(node, rect);
1501    let can_cache_scene = scene_cache_allowed && can_use_boundary_cache && node.parent.is_some();
1502    if can_cache_scene {
1503        if let Some(entry) = boundary_cache.get(&node_id) {
1504            if entry.hash == scene_cache_key {
1505                *hit_count += 1;
1506                return Some(entry.layer.clone());
1507            }
1508        }
1509    } else if can_use_boundary_cache {
1510        if let Some(entry) = boundary_cache.get(&node_id) {
1511            if entry.hash == scene_cache_key {
1512                *hit_count += 1;
1513                return Some(entry.layer.clone());
1514            }
1515        }
1516    }
1517
1518    let composite_opacity = resolve_composite_scalar(
1519        node.composite.opacity.as_ref(),
1520        animation_map,
1521        MotionPropertyId::Opacity,
1522    );
1523    let composite_tx = resolve_composite_scalar(
1524        node.composite.translate_x.as_ref(),
1525        animation_map,
1526        MotionPropertyId::TranslateX,
1527    );
1528    let composite_ty = resolve_composite_scalar(
1529        node.composite.translate_y.as_ref(),
1530        animation_map,
1531        MotionPropertyId::TranslateY,
1532    );
1533    let composite_scale = resolve_composite_scalar(
1534        node.composite.scale.as_ref(),
1535        animation_map,
1536        MotionPropertyId::Scale,
1537    )
1538    .unwrap_or(1.0);
1539    let composite_rotation = resolve_composite_scalar(
1540        node.composite.rotation.as_ref(),
1541        animation_map,
1542        MotionPropertyId::Rotation,
1543    )
1544    .unwrap_or(0.0);
1545
1546    let _has_composite_transform = composite_tx.unwrap_or(0.0).abs() > 0.001
1547        || composite_ty.unwrap_or(0.0).abs() > 0.001
1548        || (composite_scale - 1.0).abs() > 0.001
1549        || composite_rotation.abs() > 0.001;
1550    let has_opacity_layer = composite_opacity
1551        .map(|value| (value - 1.0).abs() > 0.001)
1552        .unwrap_or(false);
1553    let needs_dynamic_opacity = node
1554        .composite
1555        .opacity
1556        .as_ref()
1557        .and_then(|value| value.motion_target)
1558        .is_some();
1559    let needs_dynamic_transform = node
1560        .composite
1561        .translate_x
1562        .as_ref()
1563        .and_then(|value| value.motion_target)
1564        .is_some()
1565        || node
1566            .composite
1567            .translate_y
1568            .as_ref()
1569            .and_then(|value| value.motion_target)
1570            .is_some()
1571        || node
1572            .composite
1573            .scale
1574            .as_ref()
1575            .and_then(|value| value.motion_target)
1576            .is_some()
1577        || node
1578            .composite
1579            .rotation
1580            .as_ref()
1581            .and_then(|value| value.motion_target)
1582            .is_some();
1583    let emit_opacity_layer = has_opacity_layer || needs_dynamic_opacity;
1584    let has_runtime_clip = node.composite.clip_to_bounds;
1585    let scroll = match &node.op {
1586        Op::Layout(LayoutOp::Scroll { direction, .. }) => Some(ScrollTransform {
1587            node_id,
1588            direction: *direction,
1589        }),
1590        _ => None,
1591    };
1592    let layout_transform = match &node.op {
1593        Op::Layout(LayoutOp::Transform { transform }) => Some(*transform),
1594        _ => None,
1595    };
1596    let has_own_transform = needs_dynamic_transform || layout_transform.is_some();
1597    let has_dynamic_transform = has_own_transform || scroll.is_some();
1598    let has_dynamic_style = emit_opacity_layer || has_dynamic_transform || has_runtime_clip;
1599    let has_dynamic_children = node.children.iter().any(|child| {
1600        runtime_dynamic_subtrees
1601            .get(child)
1602            .copied()
1603            .unwrap_or(false)
1604    });
1605    let mut layer = RenderLayer::new(rect);
1606    layer.node_id = Some(node_id);
1607    if can_cache_scene {
1608        layer.style.cache_key = Some(scene_cache_key);
1609    } else if has_dynamic_style && !has_dynamic_children {
1610        layer.style.content_cache_key = Some(scene_cache_key ^ 0x9E37_79B9_7F4A_7C15);
1611    }
1612
1613    layer.style.clip = match &node.op {
1614        Op::Layout(LayoutOp::Scroll { .. }) | Op::Layout(LayoutOp::Clip { .. }) => {
1615            Some(LayerClip::Rect(rect))
1616        }
1617        _ if has_runtime_clip => Some(LayerClip::Rect(rect)),
1618        _ => None,
1619    };
1620    if emit_opacity_layer {
1621        layer.style.opacity = composite_opacity.unwrap_or(1.0);
1622    }
1623
1624    if let Some(transform) = compose_dynamic_layer_transform(
1625        &TransformBinding {
1626            layer_path: layer_path.clone(),
1627            rect,
1628            layout_transform,
1629            scroll: None,
1630            translate_x: node.composite.translate_x.clone(),
1631            translate_y: node.composite.translate_y.clone(),
1632            scale: node.composite.scale.clone(),
1633            rotation: node.composite.rotation.clone(),
1634        },
1635        scroll_map,
1636        animation_map,
1637    ) {
1638        layer.style.transform = Some(transform);
1639    }
1640
1641    let local_hash = local_paint_hash(node);
1642    let local_paint = if let Some((cached_hash, cached_ops)) = paint_cache.get(&node_id) {
1643        if *cached_hash == local_hash {
1644            *hit_count += 1;
1645            Some(cached_ops.clone())
1646        } else {
1647            *miss_count += 1;
1648            let ops = build_local_paint_list(ir, node_id, node, rect);
1649            if let Some(ops) = ops.clone() {
1650                paint_cache.insert(node_id, (local_hash, ops));
1651            } else {
1652                paint_cache.remove(&node_id);
1653            }
1654            ops
1655        }
1656    } else {
1657        *miss_count += 1;
1658        let ops = build_local_paint_list(ir, node_id, node, rect);
1659        if let Some(ops) = ops.clone() {
1660            paint_cache.insert(node_id, (local_hash, ops));
1661        }
1662        ops
1663    };
1664
1665    if let Some(local_paint) = local_paint {
1666        layer.children.push(RenderNode::Paint(local_paint));
1667    }
1668
1669    if needs_dynamic_opacity {
1670        if let Some(scalar) = node.composite.opacity.as_ref() {
1671            bindings.opacity.push(OpacityBinding {
1672                layer_path: layer_path.clone(),
1673                scalar: scalar.clone(),
1674            });
1675        }
1676    }
1677    if has_own_transform {
1678        bindings.transform.push(TransformBinding {
1679            layer_path: layer_path.clone(),
1680            rect,
1681            layout_transform,
1682            scroll: None,
1683            translate_x: node.composite.translate_x.clone(),
1684            translate_y: node.composite.translate_y.clone(),
1685            scale: node.composite.scale.clone(),
1686            rotation: node.composite.rotation.clone(),
1687        });
1688    }
1689
1690    if let Some(scroll) = scroll {
1691        let content_index = layer.children.len();
1692        let mut content_path = layer_path.clone();
1693        content_path.push(content_index);
1694        let mut content_layer = RenderLayer::new(rect);
1695        content_layer.style.transform = compose_dynamic_layer_transform(
1696            &TransformBinding {
1697                layer_path: content_path.clone(),
1698                rect,
1699                layout_transform: None,
1700                scroll: Some(scroll.clone()),
1701                translate_x: None,
1702                translate_y: None,
1703                scale: None,
1704                rotation: None,
1705            },
1706            scroll_map,
1707            animation_map,
1708        );
1709        content_layer.style.transform_clip = false;
1710        bindings.transform.push(TransformBinding {
1711            layer_path: content_path.clone(),
1712            rect,
1713            layout_transform: None,
1714            scroll: Some(scroll),
1715            translate_x: None,
1716            translate_y: None,
1717            scale: None,
1718            rotation: None,
1719        });
1720
1721        for child in &node.children {
1722            let child_index = content_layer.children.len();
1723            let mut child_path = content_path.clone();
1724            child_path.push(child_index);
1725            if let Some(child_layer) = generate_render_layer_recursive(
1726                *child,
1727                ir,
1728                snapshot,
1729                scroll_map,
1730                animation_map,
1731                paint_cache,
1732                boundary_cache,
1733                runtime_dynamic_subtrees,
1734                miss_count,
1735                hit_count,
1736                scene_cache_allowed,
1737                visited,
1738                bindings,
1739                child_path,
1740            ) {
1741                content_layer.children.push(RenderNode::Layer(child_layer));
1742            }
1743        }
1744
1745        if !content_layer.children.is_empty() {
1746            layer.children.push(RenderNode::Layer(content_layer));
1747        }
1748    } else {
1749        for child in &node.children {
1750            let child_index = layer.children.len();
1751            let mut child_path = layer_path.clone();
1752            child_path.push(child_index);
1753            if let Some(child_layer) = generate_render_layer_recursive(
1754                *child,
1755                ir,
1756                snapshot,
1757                scroll_map,
1758                animation_map,
1759                paint_cache,
1760                boundary_cache,
1761                runtime_dynamic_subtrees,
1762                miss_count,
1763                hit_count,
1764                scene_cache_allowed,
1765                visited,
1766                bindings,
1767                child_path,
1768            ) {
1769                layer.children.push(RenderNode::Layer(child_layer));
1770            }
1771        }
1772    }
1773
1774    if let Some(scrollbar) = build_scrollbar_paint(ir, node_id, snapshot, scroll_map) {
1775        let mut scrollbar_path = layer_path.clone();
1776        scrollbar_path.push(layer.children.len());
1777        layer.children.push(RenderNode::Paint(scrollbar));
1778        bindings.scrollbar.push(ScrollbarBinding {
1779            node_path: scrollbar_path,
1780            node_id,
1781        });
1782    }
1783
1784    if can_use_boundary_cache {
1785        boundary_cache.insert(
1786            node_id,
1787            BoundaryCacheEntry {
1788                hash: scene_cache_key,
1789                layer: layer.clone(),
1790            },
1791        );
1792    }
1793
1794    Some(layer)
1795}
1796
1797fn push_video_surface(
1798    video_surfaces: &mut Vec<VideoSurfaceFrame>,
1799    widget_id: WidgetId,
1800    rect: LayoutRect,
1801    video_map: &VideoStateMap,
1802) {
1803    if let Some(state) = video_map.states.get(&widget_id) {
1804        let surface_id = state.surface_id.unwrap_or(0);
1805        video_surfaces.push(VideoSurfaceFrame {
1806            widget_id,
1807            surface_id,
1808            rect,
1809        });
1810    }
1811}
1812
1813fn push_web_surface(
1814    web_surfaces: &mut Vec<WebSurfaceFrame>,
1815    widget_id: WidgetId,
1816    rect: LayoutRect,
1817    web_map: &WebStateMap,
1818) {
1819    if let Some(state) = web_map.states.get(&widget_id) {
1820        if !state.url.trim().is_empty() {
1821            web_surfaces.push(WebSurfaceFrame {
1822                widget_id,
1823                url: state.url.clone(),
1824                user_agent: state.user_agent.clone(),
1825                rect,
1826            });
1827        }
1828    }
1829}
1830
1831fn collect_video_surfaces(
1832    node_id: WidgetId,
1833    ir: &CoreIR,
1834    snapshot: &LayoutSnapshot,
1835    video_map: &VideoStateMap,
1836    web_map: &WebStateMap,
1837    scroll_map: &ScrollStateMap,
1838    accumulated_offset: LayoutPoint,
1839    video_surfaces: &mut Vec<VideoSurfaceFrame>,
1840    web_surfaces: &mut Vec<WebSurfaceFrame>,
1841    scene_3d_surfaces: &mut Vec<(WidgetId, LayoutRect, Vec<u8>)>,
1842) {
1843    let mut visited = HashSet::new();
1844    collect_video_surfaces_with_visited(
1845        node_id,
1846        ir,
1847        snapshot,
1848        video_map,
1849        web_map,
1850        scroll_map,
1851        accumulated_offset,
1852        video_surfaces,
1853        web_surfaces,
1854        scene_3d_surfaces,
1855        &mut visited,
1856    );
1857}
1858
1859fn collect_video_surfaces_with_visited(
1860    node_id: WidgetId,
1861    ir: &CoreIR,
1862    snapshot: &LayoutSnapshot,
1863    video_map: &VideoStateMap,
1864    web_map: &WebStateMap,
1865    scroll_map: &ScrollStateMap,
1866    accumulated_offset: LayoutPoint,
1867    video_surfaces: &mut Vec<VideoSurfaceFrame>,
1868    web_surfaces: &mut Vec<WebSurfaceFrame>,
1869    scene_3d_surfaces: &mut Vec<(WidgetId, LayoutRect, Vec<u8>)>,
1870    visited: &mut HashSet<WidgetId>,
1871) {
1872    if !visited.insert(node_id) {
1873        return;
1874    }
1875    if let (Some(node), Some(geom)) = (ir.nodes.get(&node_id), snapshot.nodes.get(&node_id)) {
1876        let mut child_offset = accumulated_offset;
1877        if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
1878            let offset = scroll_map.get_offset(node_id);
1879            child_offset = match direction {
1880                fission_ir::FlexDirection::Row => {
1881                    LayoutPoint::new(accumulated_offset.x - offset, accumulated_offset.y)
1882                }
1883                fission_ir::FlexDirection::Column => {
1884                    LayoutPoint::new(accumulated_offset.x, accumulated_offset.y - offset)
1885                }
1886            };
1887        }
1888
1889        if let Op::Layout(LayoutOp::Embed {
1890            kind: EmbedKind::Video,
1891            widget_id,
1892            ..
1893        }) = &node.op
1894        {
1895            let translated_rect = translate_rect(geom.rect, accumulated_offset);
1896            push_video_surface(video_surfaces, *widget_id, translated_rect, video_map);
1897        } else if let Op::Layout(LayoutOp::Embed {
1898            kind: EmbedKind::Web,
1899            widget_id,
1900            ..
1901        }) = &node.op
1902        {
1903            let translated_rect = translate_rect(geom.rect, accumulated_offset);
1904            push_web_surface(web_surfaces, *widget_id, translated_rect, web_map);
1905        } else if let Op::Layout(LayoutOp::Embed {
1906            kind: EmbedKind::Custom(payload),
1907            widget_id,
1908            ..
1909        }) = &node.op
1910        {
1911            let translated_rect = translate_rect(geom.rect, accumulated_offset);
1912            scene_3d_surfaces.push((*widget_id, translated_rect, payload.clone()));
1913        }
1914
1915        for child in &node.children {
1916            collect_video_surfaces_with_visited(
1917                *child,
1918                ir,
1919                snapshot,
1920                video_map,
1921                web_map,
1922                scroll_map,
1923                child_offset,
1924                video_surfaces,
1925                web_surfaces,
1926                scene_3d_surfaces,
1927                visited,
1928            );
1929        }
1930    }
1931}
1932
1933fn local_paint_hash(node: &fission_ir::CoreNode) -> u64 {
1934    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1935    node.op.hash(&mut hasher);
1936    hasher.finish()
1937}
1938
1939fn boundary_hash(node: &fission_ir::CoreNode, rect: LayoutRect) -> u64 {
1940    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1941    node.hash.hash(&mut hasher);
1942    rect.origin.x.to_bits().hash(&mut hasher);
1943    rect.origin.y.to_bits().hash(&mut hasher);
1944    rect.size.width.to_bits().hash(&mut hasher);
1945    rect.size.height.to_bits().hash(&mut hasher);
1946    hasher.finish()
1947}
1948
1949fn build_local_paint_list(
1950    ir: &CoreIR,
1951    node_id: WidgetId,
1952    node: &fission_ir::CoreNode,
1953    rect: LayoutRect,
1954) -> Option<DisplayList> {
1955    let mut list = DisplayList::new(rect);
1956    match &node.op {
1957        Op::Paint(fission_ir::PaintOp::DrawRect {
1958            fill,
1959            stroke,
1960            corner_radius,
1961            shadow,
1962        }) => {
1963            list.push(DisplayOp::DrawRect {
1964                rect,
1965                fill: fill.as_ref().map(map_fill),
1966                stroke: stroke.as_ref().map(map_stroke),
1967                corner_radius: *corner_radius,
1968                shadow: shadow.as_ref().map(|s| BoxShadow {
1969                    color: RenderColor {
1970                        r: s.color.r,
1971                        g: s.color.g,
1972                        b: s.color.b,
1973                        a: s.color.a,
1974                    },
1975                    blur_radius: s.blur_radius,
1976                    offset: s.offset,
1977                }),
1978                bounds: rect,
1979                node_id: Some(node_id),
1980            });
1981        }
1982        Op::Paint(fission_ir::PaintOp::DrawText {
1983            text,
1984            size,
1985            color,
1986            underline,
1987            wrap,
1988            caret_index,
1989            caret_color,
1990            caret_width,
1991            caret_height,
1992            caret_radius,
1993            paragraph_style,
1994        }) => {
1995            list.push(DisplayOp::DrawText {
1996                text: text.clone(),
1997                position: rect.origin,
1998                size: *size,
1999                color: RenderColor {
2000                    r: color.r,
2001                    g: color.g,
2002                    b: color.b,
2003                    a: color.a,
2004                },
2005                bounds: rect,
2006                node_id: Some(node_id),
2007                underline: *underline,
2008                wrap: *wrap,
2009                caret_index: *caret_index,
2010                caret_color: caret_color.map(|color| RenderColor {
2011                    r: color.r,
2012                    g: color.g,
2013                    b: color.b,
2014                    a: color.a,
2015                }),
2016                caret_width: *caret_width,
2017                caret_height: *caret_height,
2018                caret_radius: *caret_radius,
2019                paragraph_style: *paragraph_style,
2020            });
2021        }
2022        Op::Paint(fission_ir::PaintOp::DrawRichText {
2023            runs,
2024            wrap,
2025            caret_index,
2026            caret_color,
2027            caret_width,
2028            caret_height,
2029            caret_radius,
2030            paragraph_style,
2031        }) => {
2032            let annotations = ir
2033                .custom_render_objects
2034                .get(&node_id)
2035                .and_then(|sidecar| {
2036                    sidecar.downcast_ref::<Vec<fission_ir::op::RichTextAnnotation>>()
2037                })
2038                .cloned()
2039                .unwrap_or_default();
2040            let render_runs = runs
2041                .iter()
2042                .map(|r| fission_render::TextRun {
2043                    text: r.text.clone(),
2044                    style: fission_render::TextStyle {
2045                        font_size: r.style.font_size,
2046                        color: RenderColor {
2047                            r: r.style.color.r,
2048                            g: r.style.color.g,
2049                            b: r.style.color.b,
2050                            a: r.style.color.a,
2051                        },
2052                        underline: r.style.underline,
2053                        font_family: r.style.font_family.clone(),
2054                        locale: r.style.locale.clone(),
2055                        font_weight: r.style.font_weight,
2056                        font_style: r.style.font_style,
2057                        line_height: r.style.line_height,
2058                        letter_spacing: r.style.letter_spacing,
2059                        background_color: r.style.background_color.map(|c| RenderColor {
2060                            r: c.r,
2061                            g: c.g,
2062                            b: c.b,
2063                            a: c.a,
2064                        }),
2065                    },
2066                })
2067                .collect();
2068
2069            list.push(DisplayOp::DrawRichText {
2070                runs: render_runs,
2071                position: rect.origin,
2072                bounds: rect,
2073                node_id: Some(node_id),
2074                wrap: *wrap,
2075                caret_index: *caret_index,
2076                caret_color: caret_color.map(|color| RenderColor {
2077                    r: color.r,
2078                    g: color.g,
2079                    b: color.b,
2080                    a: color.a,
2081                }),
2082                caret_width: *caret_width,
2083                caret_height: *caret_height,
2084                caret_radius: *caret_radius,
2085                paragraph_style: *paragraph_style,
2086                annotations,
2087            });
2088        }
2089        Op::Paint(fission_ir::PaintOp::DrawImage {
2090            request,
2091            fit,
2092            alignment,
2093        }) => {
2094            list.push(DisplayOp::DrawImage {
2095                rect,
2096                request: request.clone(),
2097                fit: match fit {
2098                    fission_ir::op::ImageFit::Contain => fission_render::ImageFit::Contain,
2099                    fission_ir::op::ImageFit::Cover => fission_render::ImageFit::Cover,
2100                    fission_ir::op::ImageFit::Fill => fission_render::ImageFit::Fill,
2101                    fission_ir::op::ImageFit::None => fission_render::ImageFit::None,
2102                },
2103                alignment: *alignment,
2104                bounds: rect,
2105                node_id: Some(node_id),
2106            });
2107        }
2108        Op::Paint(fission_ir::PaintOp::DrawPath { path, fill, stroke }) => {
2109            list.push(DisplayOp::DrawPath {
2110                path: path.clone(),
2111                fill: fill.as_ref().map(map_fill),
2112                stroke: stroke.as_ref().map(map_stroke),
2113                bounds: rect,
2114                node_id: Some(node_id),
2115            });
2116        }
2117        Op::Paint(fission_ir::PaintOp::DrawSvg {
2118            content,
2119            fill,
2120            stroke,
2121        }) => {
2122            list.push(DisplayOp::DrawSvg {
2123                content: content.clone(),
2124                fill: fill.as_ref().map(map_fill),
2125                stroke: stroke.as_ref().map(map_stroke),
2126                bounds: rect,
2127                node_id: Some(node_id),
2128            });
2129        }
2130        Op::Layout(LayoutOp::Embed {
2131            kind, widget_id, ..
2132        }) => {
2133            list.push(DisplayOp::DrawSurface {
2134                rect,
2135                surface_id: embed_surface_id(kind, *widget_id),
2136                position: 0,
2137                bounds: rect,
2138                node_id: Some(node_id),
2139            });
2140        }
2141        _ => {}
2142    }
2143    if list.ops.is_empty() {
2144        None
2145    } else {
2146        Some(list)
2147    }
2148}
2149
2150fn build_scrollbar_paint(
2151    ir: &CoreIR,
2152    node_id: WidgetId,
2153    snapshot: &LayoutSnapshot,
2154    scroll_map: &ScrollStateMap,
2155) -> Option<DisplayList> {
2156    let geometry = scrollbar_geometry_for_node(ir, snapshot, scroll_map, node_id)?;
2157    let rail_fill = Some(Fill::Solid(RenderColor {
2158        r: 160,
2159        g: 168,
2160        b: 180,
2161        a: 80,
2162    }));
2163    let thumb_fill = Some(Fill::Solid(RenderColor {
2164        r: 82,
2165        g: 91,
2166        b: 108,
2167        a: 190,
2168    }));
2169    let mut list = DisplayList::new(geometry.rail_rect);
2170    let corner_radius = fission_core::scrollbar::SCROLLBAR_THICKNESS / 2.0;
2171
2172    list.push(DisplayOp::DrawRect {
2173        rect: geometry.rail_rect,
2174        fill: rail_fill,
2175        stroke: None,
2176        corner_radius,
2177        shadow: None,
2178        bounds: geometry.rail_rect,
2179        node_id: Some(node_id),
2180    });
2181    list.push(DisplayOp::DrawRect {
2182        rect: geometry.thumb_rect,
2183        fill: thumb_fill,
2184        stroke: None,
2185        corner_radius,
2186        shadow: None,
2187        bounds: geometry.thumb_rect,
2188        node_id: Some(node_id),
2189    });
2190
2191    Some(list)
2192}
2193
2194fn resolve_composite_scalar(
2195    scalar: Option<&fission_ir::CompositeScalar>,
2196    animation_map: &MotionStateMap,
2197    property: MotionPropertyId,
2198) -> Option<f32> {
2199    let scalar = scalar?;
2200    Some(resolve_scalar_value(scalar, animation_map, property))
2201}
2202
2203fn resolve_scalar_value(
2204    scalar: &fission_ir::CompositeScalar,
2205    animation_map: &MotionStateMap,
2206    property: MotionPropertyId,
2207) -> f32 {
2208    scalar
2209        .motion_target
2210        .map(|target| animation_map.scalar_value(target, property))
2211        .unwrap_or(scalar.base)
2212}
2213
2214fn composite_transform_matrix(
2215    rect: LayoutRect,
2216    translate_x: f32,
2217    translate_y: f32,
2218    scale: f32,
2219    rotation: f32,
2220) -> [f32; 16] {
2221    let center_x = rect.origin.x + rect.size.width * 0.5;
2222    let center_y = rect.origin.y + rect.size.height * 0.5;
2223
2224    let to_center = translation_matrix(center_x, center_y);
2225    let from_center = translation_matrix(-center_x, -center_y);
2226    let scale_matrix = scale_matrix(scale);
2227    let rotation_matrix = rotation_z_matrix(rotation);
2228    let motion_translate = translation_matrix(translate_x, translate_y);
2229
2230    multiply_matrix(
2231        motion_translate,
2232        multiply_matrix(
2233            to_center,
2234            multiply_matrix(rotation_matrix, multiply_matrix(scale_matrix, from_center)),
2235        ),
2236    )
2237}
2238
2239fn translation_matrix(tx: f32, ty: f32) -> [f32; 16] {
2240    [
2241        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,
2242    ]
2243}
2244
2245fn scale_matrix(scale: f32) -> [f32; 16] {
2246    [
2247        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,
2248    ]
2249}
2250
2251fn rotation_z_matrix(radians: f32) -> [f32; 16] {
2252    let sin = radians.sin();
2253    let cos = radians.cos();
2254    [
2255        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,
2256    ]
2257}
2258
2259fn multiply_matrix(a: [f32; 16], b: [f32; 16]) -> [f32; 16] {
2260    let mut out = [0.0; 16];
2261    for row in 0..4 {
2262        for col in 0..4 {
2263            let mut sum = 0.0;
2264            for k in 0..4 {
2265                sum += a[row * 4 + k] * b[k * 4 + col];
2266            }
2267            out[row * 4 + col] = sum;
2268        }
2269    }
2270    out
2271}
2272
2273fn is_identity_matrix(matrix: &[f32; 16]) -> bool {
2274    const IDENTITY: [f32; 16] = [
2275        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,
2276    ];
2277    matrix
2278        .iter()
2279        .zip(IDENTITY.iter())
2280        .all(|(lhs, rhs)| (*lhs - *rhs).abs() <= 0.000_1)
2281}
2282
2283#[cfg(test)]
2284fn scroll_offsets_changed(prev: &HashMap<WidgetId, u32>, scroll_map: &ScrollStateMap) -> bool {
2285    if prev.len() != scroll_map.offsets.len() {
2286        return true;
2287    }
2288
2289    scroll_map
2290        .offsets
2291        .iter()
2292        .any(|(id, offset)| prev.get(id).copied() != Some(offset.to_bits()))
2293}
2294
2295impl SnapshotProvider for Pipeline {
2296    fn snapshot(&self, kind: SnapshotKind) -> Option<SnapshotBlob> {
2297        match kind {
2298            SnapshotKind::Layout => self.last_snapshot.as_ref().and_then(|snap| {
2299                serde_json::to_string_pretty(snap)
2300                    .ok()
2301                    .map(|json| SnapshotBlob { kind, json })
2302            }),
2303        }
2304    }
2305}
2306
2307fn map_fill(f: &fission_ir::op::Fill) -> Fill {
2308    match f {
2309        fission_ir::op::Fill::Solid(c) => Fill::Solid(RenderColor {
2310            r: c.r,
2311            g: c.g,
2312            b: c.b,
2313            a: c.a,
2314        }),
2315        fission_ir::op::Fill::LinearGradient { start, end, stops } => Fill::LinearGradient {
2316            start: *start,
2317            end: *end,
2318            stops: stops
2319                .iter()
2320                .map(|(o, c)| {
2321                    (
2322                        *o,
2323                        RenderColor {
2324                            r: c.r,
2325                            g: c.g,
2326                            b: c.b,
2327                            a: c.a,
2328                        },
2329                    )
2330                })
2331                .collect(),
2332        },
2333        fission_ir::op::Fill::RadialGradient {
2334            center,
2335            radius,
2336            stops,
2337        } => Fill::RadialGradient {
2338            center: *center,
2339            radius: *radius,
2340            stops: stops
2341                .iter()
2342                .map(|(o, c)| {
2343                    (
2344                        *o,
2345                        RenderColor {
2346                            r: c.r,
2347                            g: c.g,
2348                            b: c.b,
2349                            a: c.a,
2350                        },
2351                    )
2352                })
2353                .collect(),
2354        },
2355    }
2356}
2357
2358fn map_stroke(s: &fission_ir::op::Stroke) -> Stroke {
2359    Stroke {
2360        fill: map_fill(&s.fill),
2361        width: s.width,
2362        dash_array: s.dash_array.clone(),
2363        line_cap: match s.line_cap {
2364            fission_ir::op::LineCap::Butt => fission_render::LineCap::Butt,
2365            fission_ir::op::LineCap::Round => fission_render::LineCap::Round,
2366            fission_ir::op::LineCap::Square => fission_render::LineCap::Square,
2367        },
2368        line_join: match s.line_join {
2369            fission_ir::op::LineJoin::Miter => fission_render::LineJoin::Miter,
2370            fission_ir::op::LineJoin::Round => fission_render::LineJoin::Round,
2371            fission_ir::op::LineJoin::Bevel => fission_render::LineJoin::Bevel,
2372        },
2373    }
2374}
2375
2376fn translate_rect(rect: LayoutRect, offset: LayoutPoint) -> LayoutRect {
2377    LayoutRect {
2378        origin: LayoutPoint::new(rect.origin.x + offset.x, rect.origin.y + offset.y),
2379        size: rect.size,
2380    }
2381}
2382
2383#[cfg(test)]
2384mod tests {
2385    use super::{build_local_paint_list, scroll_offsets_changed, InvalidationSet, Pipeline};
2386    use fission_core::env::Env;
2387    use fission_core::MotionPropertyId;
2388    use fission_core::ScrollStateMap;
2389    use fission_ir::op::{
2390        Color, Fill, ImageAlignment, ImageFit, ImageRequest, ImageSource, RichTextAnnotation,
2391        TextRun, TextStyle,
2392    };
2393    use fission_ir::semantics::ActionTrigger;
2394    use fission_ir::{
2395        ActionEntry, CompositeScalar, CompositeStyle, CoreIR, EmbedKind, LayoutOp, Op, PaintOp,
2396        WidgetId,
2397    };
2398    use fission_layout::{LayoutEngine, LayoutRect, LayoutSize};
2399    use fission_render::{DisplayOp, RenderScene, Renderer};
2400    use std::collections::HashMap;
2401    use std::sync::Arc;
2402
2403    struct NullRenderer;
2404
2405    impl Renderer for NullRenderer {
2406        fn render_scene(&mut self, _scene: &RenderScene) -> anyhow::Result<()> {
2407            Ok(())
2408        }
2409    }
2410
2411    fn two_child_layout_ir(second_width: f32) -> CoreIR {
2412        let root = WidgetId::derived(50, &[0]);
2413        let first = WidgetId::derived(50, &[1]);
2414        let second = WidgetId::derived(50, &[2]);
2415        let mut ir = CoreIR::new();
2416        ir.add_node(
2417            first,
2418            Op::Layout(LayoutOp::Box {
2419                width: Some(40.0),
2420                height: Some(20.0),
2421                min_width: None,
2422                max_width: None,
2423                min_height: None,
2424                max_height: None,
2425                padding: [0.0; 4],
2426                flex_grow: 0.0,
2427                flex_shrink: 1.0,
2428                aspect_ratio: None,
2429            }),
2430            vec![],
2431        );
2432        ir.add_node(
2433            second,
2434            Op::Layout(LayoutOp::Box {
2435                width: Some(second_width),
2436                height: Some(20.0),
2437                min_width: None,
2438                max_width: None,
2439                min_height: None,
2440                max_height: None,
2441                padding: [0.0; 4],
2442                flex_grow: 0.0,
2443                flex_shrink: 1.0,
2444                aspect_ratio: None,
2445            }),
2446            vec![],
2447        );
2448        ir.add_node(
2449            root,
2450            Op::Layout(LayoutOp::Flex {
2451                direction: fission_ir::FlexDirection::Column,
2452                wrap: fission_ir::op::FlexWrap::NoWrap,
2453                flex_grow: 0.0,
2454                flex_shrink: 1.0,
2455                padding: [0.0; 4],
2456                gap: Some(4.0),
2457                align_items: fission_ir::op::AlignItems::Start,
2458                justify_content: fission_ir::op::JustifyContent::Start,
2459            }),
2460            vec![first, second],
2461        );
2462        ir.set_root(root);
2463        ir
2464    }
2465
2466    #[test]
2467    fn unchanged_scroll_offsets_do_not_invalidate_cache() {
2468        let id = WidgetId::derived(1, &[0]);
2469        let mut prev = HashMap::new();
2470        prev.insert(id, 12.5f32.to_bits());
2471        let mut scroll = ScrollStateMap::default();
2472        scroll.set_offset(id, 12.5);
2473        assert!(!scroll_offsets_changed(&prev, &scroll));
2474    }
2475
2476    #[test]
2477    fn changed_scroll_offsets_invalidate_cache() {
2478        let id = WidgetId::derived(2, &[0]);
2479        let mut prev = HashMap::new();
2480        prev.insert(id, 0.0f32.to_bits());
2481        let mut scroll = ScrollStateMap::default();
2482        scroll.set_offset(id, 4.0);
2483        assert!(scroll_offsets_changed(&prev, &scroll));
2484    }
2485
2486    #[test]
2487    fn incremental_layout_keeps_rebuild_telemetry_honest() {
2488        let mut pipeline = Pipeline::new();
2489        let mut layout_engine = LayoutEngine::new();
2490        let scroll = ScrollStateMap::default();
2491
2492        pipeline.replace_ir(two_child_layout_ir(60.0), &Env::default());
2493        let first_pass = pipeline
2494            .ensure_layout(
2495                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2496                &mut layout_engine,
2497                &scroll,
2498            )
2499            .expect("initial layout");
2500        assert_eq!(first_pass, pipeline.layout_input_nodes.len());
2501        assert_eq!(pipeline.layout_full_rebuild_count, 1);
2502
2503        pipeline.replace_ir(two_child_layout_ir(90.0), &Env::default());
2504        let second_pass = pipeline
2505            .ensure_layout(
2506                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2507                &mut layout_engine,
2508                &scroll,
2509            )
2510            .expect("incremental layout");
2511
2512        assert_eq!(second_pass, 1);
2513        assert_eq!(pipeline.layout_full_rebuild_count, 1);
2514        assert!(pipeline.pending_layout_dirty_nodes.is_empty());
2515    }
2516
2517    #[test]
2518    fn rich_text_annotations_flow_into_display_ops() {
2519        let node_id = WidgetId::derived(9, &[0]);
2520        let mut ir = CoreIR::new();
2521        ir.add_node(
2522            node_id,
2523            Op::Paint(PaintOp::DrawRichText {
2524                runs: vec![TextRun {
2525                    text: "docs".into(),
2526                    style: TextStyle {
2527                        font_size: 14.0,
2528                        color: Color::BLACK,
2529                        underline: false,
2530                        font_family: None,
2531                        locale: None,
2532                        font_weight: 400,
2533                        font_style: fission_ir::op::FontStyle::Normal,
2534                        line_height: None,
2535                        letter_spacing: 0.0,
2536                        background_color: None,
2537                    },
2538                }],
2539                wrap: true,
2540                caret_index: None,
2541                caret_color: None,
2542                caret_width: None,
2543                caret_height: None,
2544                caret_radius: None,
2545                paragraph_style: None,
2546            }),
2547            vec![],
2548        );
2549        ir.custom_render_objects.insert(
2550            node_id,
2551            Arc::new(vec![RichTextAnnotation {
2552                range: 0..4,
2553                semantics_label: Some("Documentation".into()),
2554                semantics_identifier: Some("docs-link".into()),
2555                spell_out: Some(true),
2556                mouse_cursor: Some(fission_ir::op::MouseCursor::Pointer),
2557                actions: vec![ActionEntry {
2558                    trigger: ActionTrigger::Default,
2559                    action_id: 7,
2560                    payload_data: Some(vec![1, 2, 3]),
2561                }],
2562            }]),
2563        );
2564
2565        let node = ir.nodes.get(&node_id).expect("paint node");
2566        let list =
2567            build_local_paint_list(&ir, node_id, node, LayoutRect::new(0.0, 0.0, 160.0, 40.0))
2568                .expect("display list");
2569        match list.ops.first() {
2570            Some(DisplayOp::DrawRichText { annotations, .. }) => {
2571                assert_eq!(annotations.len(), 1);
2572                assert_eq!(annotations[0].range, 0..4);
2573                assert_eq!(
2574                    annotations[0].semantics_identifier.as_deref(),
2575                    Some("docs-link")
2576                );
2577            }
2578            other => panic!("expected rich text op, got {other:?}"),
2579        }
2580    }
2581
2582    #[test]
2583    fn draw_image_paint_ops_flow_into_display_ops() {
2584        let node_id = WidgetId::derived(12, &[0]);
2585        let request = ImageRequest {
2586            source: ImageSource::Network {
2587                url: "https://example.com/product.webp".into(),
2588                headers: Vec::new(),
2589                cache_policy: Default::default(),
2590            },
2591            cache_width: Some(220),
2592            cache_height: Some(160),
2593            semantic_label: Some("Product thumbnail".into()),
2594            ..Default::default()
2595        };
2596        let mut ir = CoreIR::new();
2597        ir.add_node(
2598            node_id,
2599            Op::Paint(PaintOp::DrawImage {
2600                request: request.clone(),
2601                fit: ImageFit::Cover,
2602                alignment: ImageAlignment::Center,
2603            }),
2604            vec![],
2605        );
2606
2607        let node = ir.nodes.get(&node_id).expect("image node");
2608        let rect = LayoutRect::new(24.0, 32.0, 220.0, 160.0);
2609        let list = build_local_paint_list(&ir, node_id, node, rect).expect("display list");
2610
2611        match list.ops.first() {
2612            Some(DisplayOp::DrawImage {
2613                rect: image_rect,
2614                request: image_request,
2615                fit,
2616                alignment,
2617                bounds,
2618                node_id: Some(image_node_id),
2619            }) => {
2620                assert_eq!(*image_rect, rect);
2621                assert_eq!(image_request, &request);
2622                assert_eq!(*fit, fission_render::ImageFit::Cover);
2623                assert_eq!(*alignment, ImageAlignment::Center);
2624                assert_eq!(*bounds, rect);
2625                assert_eq!(*image_node_id, node_id);
2626            }
2627            other => panic!("expected image display op, got {other:?}"),
2628        }
2629    }
2630
2631    #[test]
2632    fn retained_pipeline_scene_keeps_draw_image_ops() {
2633        let image_id = WidgetId::derived(13, &[0]);
2634        let root_id = WidgetId::derived(13, &[1]);
2635        let request = ImageRequest {
2636            source: ImageSource::Network {
2637                url: "https://example.com/catalog/thumbnail.webp".into(),
2638                headers: Vec::new(),
2639                cache_policy: Default::default(),
2640            },
2641            semantic_label: Some("Catalog thumbnail".into()),
2642            ..Default::default()
2643        };
2644        let mut ir = CoreIR::new();
2645        ir.add_node(
2646            image_id,
2647            Op::Paint(PaintOp::DrawImage {
2648                request: request.clone(),
2649                fit: ImageFit::Cover,
2650                alignment: ImageAlignment::Center,
2651            }),
2652            vec![],
2653        );
2654        ir.add_node(
2655            root_id,
2656            Op::Layout(LayoutOp::Box {
2657                width: Some(220.0),
2658                height: Some(160.0),
2659                min_width: None,
2660                max_width: None,
2661                min_height: None,
2662                max_height: None,
2663                padding: [0.0, 0.0, 0.0, 0.0],
2664                flex_grow: 0.0,
2665                flex_shrink: 0.0,
2666                aspect_ratio: None,
2667            }),
2668            vec![image_id],
2669        );
2670        ir.set_root(root_id);
2671
2672        let mut pipeline = Pipeline::new();
2673        let mut layout_engine = LayoutEngine::new();
2674        let scroll = ScrollStateMap::default();
2675        pipeline.replace_ir(ir, &Env::default());
2676        pipeline
2677            .ensure_layout(
2678                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2679                &mut layout_engine,
2680                &scroll,
2681            )
2682            .unwrap();
2683        pipeline
2684            .prepare_current(
2685                LayoutSize {
2686                    width: 320.0,
2687                    height: 240.0,
2688                },
2689                LayoutSize {
2690                    width: 320.0,
2691                    height: 240.0,
2692                },
2693                false,
2694                &scroll,
2695                &Default::default(),
2696                &Default::default(),
2697                &Default::default(),
2698            )
2699            .unwrap();
2700
2701        let display_list = pipeline.retained_scene().expect("retained scene").flatten();
2702        let image_op = display_list.ops.iter().find_map(|op| match op {
2703            DisplayOp::DrawImage {
2704                rect,
2705                request: image_request,
2706                fit,
2707                alignment,
2708                ..
2709            } => Some((rect, image_request, fit, alignment)),
2710            _ => None,
2711        });
2712
2713        let Some((rect, image_request, fit, alignment)) = image_op else {
2714            panic!("retained scene dropped DrawImage op");
2715        };
2716        assert_eq!(image_request, &request);
2717        assert_eq!(*fit, fission_render::ImageFit::Cover);
2718        assert_eq!(*alignment, ImageAlignment::Center);
2719        assert_eq!(rect.size.width, 220.0);
2720        assert_eq!(rect.size.height, 160.0);
2721    }
2722
2723    #[test]
2724    fn embed_layout_ops_flow_into_surface_display_ops() {
2725        let node_id = WidgetId::derived(14, &[0]);
2726        let widget_id = WidgetId::explicit("embed.surface");
2727        let mut ir = CoreIR::new();
2728        ir.add_node(
2729            node_id,
2730            Op::Layout(LayoutOp::Embed {
2731                kind: EmbedKind::Web,
2732                widget_id,
2733                width: Some(320.0),
2734                height: Some(180.0),
2735            }),
2736            vec![],
2737        );
2738
2739        let node = ir.nodes.get(&node_id).expect("embed node");
2740        let rect = LayoutRect::new(12.0, 24.0, 320.0, 180.0);
2741        let list = build_local_paint_list(&ir, node_id, node, rect).expect("display list");
2742
2743        match list.ops.first() {
2744            Some(DisplayOp::DrawSurface {
2745                rect: surface_rect,
2746                bounds,
2747                node_id: Some(surface_node_id),
2748                ..
2749            }) => {
2750                assert_eq!(*surface_rect, rect);
2751                assert_eq!(*bounds, rect);
2752                assert_eq!(*surface_node_id, node_id);
2753            }
2754            other => panic!("expected surface display op, got {other:?}"),
2755        }
2756    }
2757
2758    #[test]
2759    fn compositor_bound_opacity_animation_is_composite_only() {
2760        let mut ir = CoreIR::new();
2761        let child = WidgetId::derived(10, &[1]);
2762        let root = WidgetId::derived(10, &[0]);
2763        ir.add_node(child, Op::Layout(LayoutOp::AbsoluteFill), vec![]);
2764        ir.add_node_with_composite(
2765            root,
2766            Op::Structural(fission_ir::StructuralOp::Group { stable_hash: 1 }),
2767            CompositeStyle {
2768                opacity: Some(CompositeScalar::new(0.0).motion(WidgetId::explicit("fade"))),
2769                ..Default::default()
2770            },
2771            vec![child],
2772        );
2773        ir.set_root(root);
2774
2775        let mut pipeline = Pipeline::new();
2776        pipeline.replace_ir(ir, &Env::default());
2777        let invalidation = pipeline
2778            .classify_animation_updates(&[(WidgetId::explicit("fade"), MotionPropertyId::Opacity)]);
2779        assert_eq!(
2780            invalidation,
2781            InvalidationSet {
2782                build: false,
2783                layout: false,
2784                paint: false,
2785                composite: true,
2786            }
2787        );
2788    }
2789
2790    #[test]
2791    fn unbound_custom_animation_requires_build() {
2792        let pipeline = Pipeline::new();
2793        let invalidation = pipeline.classify_animation_updates(&[(
2794            WidgetId::explicit("custom"),
2795            MotionPropertyId::custom("phase"),
2796        )]);
2797        assert!(invalidation.build);
2798        assert!(invalidation.layout);
2799    }
2800
2801    #[test]
2802    fn compositor_bound_translate_animation_is_composite_only() {
2803        let mut ir = CoreIR::new();
2804        let child = WidgetId::derived(11, &[1]);
2805        let root = WidgetId::derived(11, &[0]);
2806        ir.add_node(
2807            child,
2808            Op::Paint(PaintOp::DrawRect {
2809                fill: Some(Fill::Solid(Color {
2810                    r: 0,
2811                    g: 0,
2812                    b: 0,
2813                    a: 255,
2814                })),
2815                stroke: None,
2816                corner_radius: 0.0,
2817                shadow: None,
2818            }),
2819            vec![],
2820        );
2821        ir.add_node_with_composite(
2822            root,
2823            Op::Layout(LayoutOp::Box {
2824                width: Some(120.0),
2825                height: Some(64.0),
2826                min_width: None,
2827                max_width: None,
2828                min_height: None,
2829                max_height: None,
2830                padding: [0.0, 0.0, 0.0, 0.0],
2831                flex_grow: 0.0,
2832                flex_shrink: 0.0,
2833                aspect_ratio: None,
2834            }),
2835            CompositeStyle {
2836                translate_x: Some(CompositeScalar::new(12.0).motion(WidgetId::explicit("slide"))),
2837                ..Default::default()
2838            },
2839            vec![child],
2840        );
2841        ir.set_root(root);
2842
2843        let mut pipeline = Pipeline::new();
2844        pipeline.replace_ir(ir, &Env::default());
2845        let invalidation = pipeline.classify_animation_updates(&[(
2846            WidgetId::explicit("slide"),
2847            MotionPropertyId::TranslateX,
2848        )]);
2849        assert_eq!(
2850            invalidation,
2851            InvalidationSet {
2852                build: false,
2853                layout: false,
2854                paint: false,
2855                composite: true,
2856            }
2857        );
2858    }
2859
2860    #[test]
2861    fn dynamic_layer_with_static_contents_gets_content_cache_key() {
2862        let mut ir = CoreIR::new();
2863        let child = WidgetId::derived(12, &[1]);
2864        let root = WidgetId::derived(12, &[0]);
2865        ir.add_node(
2866            child,
2867            Op::Paint(PaintOp::DrawRect {
2868                fill: Some(Fill::Solid(Color {
2869                    r: 20,
2870                    g: 40,
2871                    b: 60,
2872                    a: 255,
2873                })),
2874                stroke: None,
2875                corner_radius: 8.0,
2876                shadow: None,
2877            }),
2878            vec![],
2879        );
2880        ir.add_node_with_composite(
2881            root,
2882            Op::Layout(LayoutOp::Box {
2883                width: Some(160.0),
2884                height: Some(72.0),
2885                min_width: None,
2886                max_width: None,
2887                min_height: None,
2888                max_height: None,
2889                padding: [0.0, 0.0, 0.0, 0.0],
2890                flex_grow: 0.0,
2891                flex_shrink: 0.0,
2892                aspect_ratio: None,
2893            }),
2894            CompositeStyle {
2895                opacity: Some(CompositeScalar::new(0.4).motion(WidgetId::explicit("fade-cache"))),
2896                ..Default::default()
2897            },
2898            vec![child],
2899        );
2900        ir.set_root(root);
2901
2902        let mut pipeline = Pipeline::new();
2903        let mut layout_engine = LayoutEngine::new();
2904        let mut renderer = NullRenderer;
2905        let scroll = ScrollStateMap::default();
2906        pipeline.replace_ir(ir, &Env::default());
2907        pipeline
2908            .ensure_layout(
2909                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
2910                &mut layout_engine,
2911                &scroll,
2912            )
2913            .unwrap();
2914        pipeline
2915            .render_current(
2916                LayoutSize {
2917                    width: 320.0,
2918                    height: 240.0,
2919                },
2920                LayoutSize {
2921                    width: 320.0,
2922                    height: 240.0,
2923                },
2924                false,
2925                &mut renderer,
2926                &scroll,
2927                &Default::default(),
2928                &Default::default(),
2929                &Default::default(),
2930            )
2931            .unwrap();
2932
2933        let scene = pipeline
2934            .retained_scene
2935            .as_ref()
2936            .expect("retained scene missing");
2937        let presentation_root = match scene.roots.first() {
2938            Some(fission_render::RenderNode::Layer(layer)) => layer,
2939            _ => panic!("missing presentation layer"),
2940        };
2941        let animated_layer = match presentation_root.children.first() {
2942            Some(fission_render::RenderNode::Layer(layer)) => layer,
2943            _ => panic!("missing animated layer"),
2944        };
2945
2946        assert!(animated_layer.style.cache_key.is_none());
2947        assert!(animated_layer.style.content_cache_key.is_some());
2948    }
2949
2950    #[test]
2951    fn nested_dynamic_descendant_becomes_child_texture_plan() {
2952        let mut ir = CoreIR::new();
2953        let left_paint = WidgetId::derived(13, &[0]);
2954        let animated_paint = WidgetId::derived(13, &[1]);
2955        let animated_wrapper = WidgetId::derived(13, &[2]);
2956        let outer_static = WidgetId::derived(13, &[3]);
2957        let outer_group = WidgetId::derived(13, &[4]);
2958        let root = WidgetId::derived(13, &[5]);
2959
2960        ir.add_node(
2961            left_paint,
2962            Op::Paint(PaintOp::DrawRect {
2963                fill: Some(Fill::Solid(Color {
2964                    r: 10,
2965                    g: 10,
2966                    b: 10,
2967                    a: 255,
2968                })),
2969                stroke: None,
2970                corner_radius: 0.0,
2971                shadow: None,
2972            }),
2973            vec![],
2974        );
2975        ir.add_node(
2976            animated_paint,
2977            Op::Paint(PaintOp::DrawRect {
2978                fill: Some(Fill::Solid(Color {
2979                    r: 200,
2980                    g: 40,
2981                    b: 40,
2982                    a: 255,
2983                })),
2984                stroke: None,
2985                corner_radius: 0.0,
2986                shadow: None,
2987            }),
2988            vec![],
2989        );
2990        ir.add_node_with_composite(
2991            animated_wrapper,
2992            Op::Layout(LayoutOp::Box {
2993                width: Some(96.0),
2994                height: Some(96.0),
2995                min_width: None,
2996                max_width: None,
2997                min_height: None,
2998                max_height: None,
2999                padding: [0.0, 0.0, 0.0, 0.0],
3000                flex_grow: 0.0,
3001                flex_shrink: 0.0,
3002                aspect_ratio: None,
3003            }),
3004            CompositeStyle {
3005                opacity: Some(CompositeScalar::new(0.4).motion(WidgetId::explicit("nested-fade"))),
3006                ..Default::default()
3007            },
3008            vec![animated_paint],
3009        );
3010        ir.add_node(
3011            outer_static,
3012            Op::Paint(PaintOp::DrawRect {
3013                fill: Some(Fill::Solid(Color {
3014                    r: 20,
3015                    g: 100,
3016                    b: 180,
3017                    a: 255,
3018                })),
3019                stroke: None,
3020                corner_radius: 8.0,
3021                shadow: None,
3022            }),
3023            vec![],
3024        );
3025        ir.add_node(
3026            outer_group,
3027            Op::Layout(LayoutOp::Box {
3028                width: Some(160.0),
3029                height: Some(120.0),
3030                min_width: None,
3031                max_width: None,
3032                min_height: None,
3033                max_height: None,
3034                padding: [0.0, 0.0, 0.0, 0.0],
3035                flex_grow: 0.0,
3036                flex_shrink: 0.0,
3037                aspect_ratio: None,
3038            }),
3039            vec![outer_static, animated_wrapper],
3040        );
3041        ir.add_node(
3042            root,
3043            Op::Layout(LayoutOp::Box {
3044                width: Some(320.0),
3045                height: Some(240.0),
3046                min_width: None,
3047                max_width: None,
3048                min_height: None,
3049                max_height: None,
3050                padding: [0.0, 0.0, 0.0, 0.0],
3051                flex_grow: 0.0,
3052                flex_shrink: 0.0,
3053                aspect_ratio: None,
3054            }),
3055            vec![left_paint, outer_group],
3056        );
3057        ir.set_root(root);
3058
3059        let mut pipeline = Pipeline::new();
3060        let mut layout_engine = LayoutEngine::new();
3061        let scroll = ScrollStateMap::default();
3062        pipeline.replace_ir(ir, &Env::default());
3063        pipeline
3064            .ensure_layout(
3065                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3066                &mut layout_engine,
3067                &scroll,
3068            )
3069            .unwrap();
3070        pipeline
3071            .prepare_current(
3072                LayoutSize {
3073                    width: 320.0,
3074                    height: 240.0,
3075                },
3076                LayoutSize {
3077                    width: 320.0,
3078                    height: 240.0,
3079                },
3080                false,
3081                &scroll,
3082                &Default::default(),
3083                &Default::default(),
3084                &Default::default(),
3085            )
3086            .unwrap();
3087
3088        let plans = pipeline.texture_compositor_plans();
3089        assert!(!plans.is_empty());
3090        assert!(
3091            plans.iter().any(|plan| !plan.children.is_empty()),
3092            "expected at least one retained texture plan to extract nested dynamic descendants"
3093        );
3094    }
3095
3096    #[test]
3097    fn resize_preview_keeps_texture_compositor_root_transform() {
3098        let mut ir = CoreIR::new();
3099        let left = WidgetId::derived(14, &[0]);
3100        let right = WidgetId::derived(14, &[1]);
3101        let root = WidgetId::derived(14, &[2]);
3102
3103        ir.add_node(
3104            left,
3105            Op::Paint(PaintOp::DrawRect {
3106                fill: Some(Fill::Solid(Color {
3107                    r: 80,
3108                    g: 80,
3109                    b: 80,
3110                    a: 255,
3111                })),
3112                stroke: None,
3113                corner_radius: 0.0,
3114                shadow: None,
3115            }),
3116            vec![],
3117        );
3118        ir.add_node(
3119            right,
3120            Op::Paint(PaintOp::DrawRect {
3121                fill: Some(Fill::Solid(Color {
3122                    r: 180,
3123                    g: 180,
3124                    b: 180,
3125                    a: 255,
3126                })),
3127                stroke: None,
3128                corner_radius: 0.0,
3129                shadow: None,
3130            }),
3131            vec![],
3132        );
3133        ir.add_node(
3134            root,
3135            Op::Layout(LayoutOp::Box {
3136                width: Some(300.0),
3137                height: Some(200.0),
3138                min_width: None,
3139                max_width: None,
3140                min_height: None,
3141                max_height: None,
3142                padding: [0.0, 0.0, 0.0, 0.0],
3143                flex_grow: 0.0,
3144                flex_shrink: 0.0,
3145                aspect_ratio: None,
3146            }),
3147            vec![left, right],
3148        );
3149        ir.set_root(root);
3150
3151        let mut pipeline = Pipeline::new();
3152        let mut layout_engine = LayoutEngine::new();
3153        let scroll = ScrollStateMap::default();
3154        pipeline.replace_ir(ir, &Env::default());
3155        pipeline
3156            .ensure_layout(
3157                LayoutRect::new(0.0, 0.0, 300.0, 200.0),
3158                &mut layout_engine,
3159                &scroll,
3160            )
3161            .unwrap();
3162        pipeline
3163            .prepare_current(
3164                LayoutSize {
3165                    width: 540.0,
3166                    height: 360.0,
3167                },
3168                LayoutSize {
3169                    width: 300.0,
3170                    height: 200.0,
3171                },
3172                true,
3173                &scroll,
3174                &Default::default(),
3175                &Default::default(),
3176                &Default::default(),
3177            )
3178            .unwrap();
3179
3180        assert!(pipeline.texture_compositor_root_transform().is_none());
3181        assert!(!pipeline.texture_compositor_plans().is_empty());
3182    }
3183
3184    #[test]
3185    fn scroll_only_layers_patch_retained_transforms_after_offset_changes() {
3186        let mut ir = CoreIR::new();
3187        let content = WidgetId::derived(15, &[0]);
3188        let scroll = WidgetId::derived(15, &[1]);
3189        let root = WidgetId::derived(15, &[2]);
3190
3191        ir.add_node(
3192            content,
3193            Op::Paint(PaintOp::DrawRect {
3194                fill: Some(Fill::Solid(Color {
3195                    r: 120,
3196                    g: 120,
3197                    b: 220,
3198                    a: 255,
3199                })),
3200                stroke: None,
3201                corner_radius: 0.0,
3202                shadow: None,
3203            }),
3204            vec![],
3205        );
3206        ir.add_node(
3207            scroll,
3208            Op::Layout(LayoutOp::Scroll {
3209                direction: fission_ir::FlexDirection::Column,
3210                show_scrollbar: true,
3211                width: Some(320.0),
3212                height: Some(240.0),
3213                min_width: None,
3214                max_width: None,
3215                min_height: None,
3216                max_height: None,
3217                padding: [0.0, 0.0, 0.0, 0.0],
3218                flex_grow: 0.0,
3219                flex_shrink: 0.0,
3220            }),
3221            vec![content],
3222        );
3223        ir.add_node(
3224            root,
3225            Op::Layout(LayoutOp::Box {
3226                width: Some(320.0),
3227                height: Some(240.0),
3228                min_width: None,
3229                max_width: None,
3230                min_height: None,
3231                max_height: None,
3232                padding: [0.0, 0.0, 0.0, 0.0],
3233                flex_grow: 0.0,
3234                flex_shrink: 0.0,
3235                aspect_ratio: None,
3236            }),
3237            vec![scroll],
3238        );
3239        ir.set_root(root);
3240
3241        let mut pipeline = Pipeline::new();
3242        let mut layout_engine = LayoutEngine::new();
3243        let scroll0 = ScrollStateMap::default();
3244        pipeline.replace_ir(ir, &Env::default());
3245        pipeline
3246            .ensure_layout(
3247                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3248                &mut layout_engine,
3249                &scroll0,
3250            )
3251            .unwrap();
3252        pipeline
3253            .prepare_current(
3254                LayoutSize {
3255                    width: 320.0,
3256                    height: 240.0,
3257                },
3258                LayoutSize {
3259                    width: 320.0,
3260                    height: 240.0,
3261                },
3262                false,
3263                &scroll0,
3264                &Default::default(),
3265                &Default::default(),
3266                &Default::default(),
3267            )
3268            .unwrap();
3269
3270        let mut scroll1 = ScrollStateMap::default();
3271        scroll1.set_offset(scroll, 180.0);
3272        pipeline
3273            .prepare_current(
3274                LayoutSize {
3275                    width: 320.0,
3276                    height: 240.0,
3277                },
3278                LayoutSize {
3279                    width: 320.0,
3280                    height: 240.0,
3281                },
3282                false,
3283                &scroll1,
3284                &Default::default(),
3285                &Default::default(),
3286                &Default::default(),
3287            )
3288            .unwrap();
3289
3290        fn find_layer_by_node(
3291            node: &fission_render::RenderNode,
3292            node_id: WidgetId,
3293        ) -> Option<&fission_render::RenderLayer> {
3294            match node {
3295                fission_render::RenderNode::Paint(_) => None,
3296                fission_render::RenderNode::Layer(layer) => {
3297                    if layer.node_id == Some(node_id) {
3298                        return Some(layer);
3299                    }
3300                    for child in &layer.children {
3301                        if let Some(found) = find_layer_by_node(child, node_id) {
3302                            return Some(found);
3303                        }
3304                    }
3305                    None
3306                }
3307            }
3308        }
3309
3310        let scroll_layer = pipeline
3311            .retained_scene()
3312            .and_then(|scene| {
3313                scene
3314                    .roots
3315                    .iter()
3316                    .find_map(|node| find_layer_by_node(node, scroll))
3317            })
3318            .expect("expected a retained scroll layer");
3319        assert!(
3320            scroll_layer.style.transform.is_none(),
3321            "scrollbar chrome must not inherit the content scroll transform"
3322        );
3323        let transform = scroll_layer
3324            .children
3325            .iter()
3326            .find_map(|child| match child {
3327                fission_render::RenderNode::Layer(layer) => layer.style.transform,
3328                fission_render::RenderNode::Paint(_) => None,
3329            })
3330            .expect("scroll content layer should carry a compositor transform");
3331        assert!(
3332            (transform[13] + 180.0).abs() <= 0.01,
3333            "expected retained content transform to patch to -180, got {}",
3334            transform[13]
3335        );
3336    }
3337
3338    #[test]
3339    fn scrollbar_thumb_patches_after_scroll_offset_changes() {
3340        let mut ir = CoreIR::new();
3341        let fill = WidgetId::derived(18, &[0]);
3342        let content = WidgetId::derived(18, &[1]);
3343        let scroll = WidgetId::derived(18, &[2]);
3344        let root = WidgetId::derived(18, &[3]);
3345
3346        ir.add_node(
3347            fill,
3348            Op::Paint(PaintOp::DrawRect {
3349                fill: Some(Fill::Solid(Color {
3350                    r: 120,
3351                    g: 120,
3352                    b: 220,
3353                    a: 255,
3354                })),
3355                stroke: None,
3356                corner_radius: 0.0,
3357                shadow: None,
3358            }),
3359            vec![],
3360        );
3361        ir.add_node(
3362            content,
3363            Op::Layout(LayoutOp::Box {
3364                width: Some(320.0),
3365                height: Some(640.0),
3366                min_width: None,
3367                max_width: None,
3368                min_height: None,
3369                max_height: None,
3370                padding: [0.0, 0.0, 0.0, 0.0],
3371                flex_grow: 0.0,
3372                flex_shrink: 0.0,
3373                aspect_ratio: None,
3374            }),
3375            vec![fill],
3376        );
3377        ir.add_node(
3378            scroll,
3379            Op::Layout(LayoutOp::Scroll {
3380                direction: fission_ir::FlexDirection::Column,
3381                show_scrollbar: true,
3382                width: Some(320.0),
3383                height: Some(240.0),
3384                min_width: None,
3385                max_width: None,
3386                min_height: None,
3387                max_height: None,
3388                padding: [0.0, 0.0, 0.0, 0.0],
3389                flex_grow: 0.0,
3390                flex_shrink: 0.0,
3391            }),
3392            vec![content],
3393        );
3394        ir.add_node(
3395            root,
3396            Op::Layout(LayoutOp::Box {
3397                width: Some(320.0),
3398                height: Some(240.0),
3399                min_width: None,
3400                max_width: None,
3401                min_height: None,
3402                max_height: None,
3403                padding: [0.0, 0.0, 0.0, 0.0],
3404                flex_grow: 0.0,
3405                flex_shrink: 0.0,
3406                aspect_ratio: None,
3407            }),
3408            vec![scroll],
3409        );
3410        ir.set_root(root);
3411
3412        let mut pipeline = Pipeline::new();
3413        let mut layout_engine = LayoutEngine::new();
3414        let scroll0 = ScrollStateMap::default();
3415        pipeline.replace_ir(ir, &Env::default());
3416        pipeline
3417            .ensure_layout(
3418                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3419                &mut layout_engine,
3420                &scroll0,
3421            )
3422            .unwrap();
3423        pipeline
3424            .prepare_current(
3425                LayoutSize::new(320.0, 240.0),
3426                LayoutSize::new(320.0, 240.0),
3427                false,
3428                &scroll0,
3429                &Default::default(),
3430                &Default::default(),
3431                &Default::default(),
3432            )
3433            .unwrap();
3434        let initial_thumb_y = scrollbar_thumb_y(pipeline.retained_scene().unwrap(), scroll)
3435            .expect("initial scrollbar thumb");
3436
3437        let mut scroll1 = ScrollStateMap::default();
3438        scroll1.set_offset(scroll, 200.0);
3439        pipeline
3440            .prepare_current(
3441                LayoutSize::new(320.0, 240.0),
3442                LayoutSize::new(320.0, 240.0),
3443                false,
3444                &scroll1,
3445                &Default::default(),
3446                &Default::default(),
3447                &Default::default(),
3448            )
3449            .unwrap();
3450        let moved_thumb_y = scrollbar_thumb_y(pipeline.retained_scene().unwrap(), scroll)
3451            .expect("moved scrollbar thumb");
3452
3453        assert!(
3454            moved_thumb_y > initial_thumb_y,
3455            "body scroll must patch the retained scrollbar thumb, before={initial_thumb_y}, after={moved_thumb_y}"
3456        );
3457
3458        fn scrollbar_thumb_y(scene: &fission_render::RenderScene, scroll: WidgetId) -> Option<f32> {
3459            fn find(node: &fission_render::RenderNode, scroll: WidgetId) -> Option<f32> {
3460                match node {
3461                    fission_render::RenderNode::Paint(list) => list.ops.iter().find_map(|op| {
3462                        if let fission_render::DisplayOp::DrawRect { rect, node_id, .. } = op {
3463                            if *node_id == Some(scroll)
3464                                && (rect.width() - 6.0).abs() <= 0.01
3465                                && rect.height() < 200.0
3466                            {
3467                                return Some(rect.origin.y);
3468                            }
3469                        }
3470                        None
3471                    }),
3472                    fission_render::RenderNode::Layer(layer) => {
3473                        layer.children.iter().find_map(|child| find(child, scroll))
3474                    }
3475                }
3476            }
3477            scene.roots.iter().find_map(|root| find(root, scroll))
3478        }
3479    }
3480
3481    #[test]
3482    fn overflowing_scroll_nodes_emit_visible_scroll_rails() {
3483        let mut ir = CoreIR::new();
3484        let fill = WidgetId::derived(16, &[0]);
3485        let content = WidgetId::derived(16, &[1]);
3486        let scroll = WidgetId::derived(16, &[2]);
3487        let root = WidgetId::derived(16, &[3]);
3488
3489        ir.add_node(
3490            fill,
3491            Op::Paint(PaintOp::DrawRect {
3492                fill: Some(Fill::Solid(Color {
3493                    r: 80,
3494                    g: 120,
3495                    b: 220,
3496                    a: 255,
3497                })),
3498                stroke: None,
3499                corner_radius: 0.0,
3500                shadow: None,
3501            }),
3502            vec![],
3503        );
3504        ir.add_node(
3505            content,
3506            Op::Layout(LayoutOp::Box {
3507                width: Some(320.0),
3508                height: Some(640.0),
3509                min_width: None,
3510                max_width: None,
3511                min_height: None,
3512                max_height: None,
3513                padding: [0.0, 0.0, 0.0, 0.0],
3514                flex_grow: 0.0,
3515                flex_shrink: 0.0,
3516                aspect_ratio: None,
3517            }),
3518            vec![fill],
3519        );
3520        ir.add_node(
3521            scroll,
3522            Op::Layout(LayoutOp::Scroll {
3523                direction: fission_ir::FlexDirection::Column,
3524                show_scrollbar: true,
3525                width: Some(320.0),
3526                height: Some(240.0),
3527                min_width: None,
3528                max_width: None,
3529                min_height: None,
3530                max_height: None,
3531                padding: [0.0, 0.0, 0.0, 0.0],
3532                flex_grow: 0.0,
3533                flex_shrink: 0.0,
3534            }),
3535            vec![content],
3536        );
3537        ir.add_node(
3538            root,
3539            Op::Layout(LayoutOp::Box {
3540                width: Some(320.0),
3541                height: Some(240.0),
3542                min_width: None,
3543                max_width: None,
3544                min_height: None,
3545                max_height: None,
3546                padding: [0.0, 0.0, 0.0, 0.0],
3547                flex_grow: 0.0,
3548                flex_shrink: 0.0,
3549                aspect_ratio: None,
3550            }),
3551            vec![scroll],
3552        );
3553        ir.set_root(root);
3554
3555        let mut pipeline = Pipeline::new();
3556        let mut layout_engine = LayoutEngine::new();
3557        let scroll_map = ScrollStateMap::default();
3558        pipeline.replace_ir(ir, &Env::default());
3559        pipeline
3560            .ensure_layout(
3561                LayoutRect::new(0.0, 0.0, 320.0, 240.0),
3562                &mut layout_engine,
3563                &scroll_map,
3564            )
3565            .unwrap();
3566        pipeline
3567            .prepare_current(
3568                LayoutSize {
3569                    width: 320.0,
3570                    height: 240.0,
3571                },
3572                LayoutSize {
3573                    width: 320.0,
3574                    height: 240.0,
3575                },
3576                false,
3577                &scroll_map,
3578                &Default::default(),
3579                &Default::default(),
3580                &Default::default(),
3581            )
3582            .unwrap();
3583
3584        fn count_scroll_rails(node: &fission_render::RenderNode, scroll: WidgetId) -> usize {
3585            match node {
3586                fission_render::RenderNode::Paint(list) => list
3587                    .ops
3588                    .iter()
3589                    .filter(|op| match op {
3590                        fission_render::DisplayOp::DrawRect { rect, node_id, .. } => {
3591                            *node_id == Some(scroll)
3592                                && (rect.width() - 6.0).abs() <= 0.01
3593                                && rect.height() >= 200.0
3594                        }
3595                        _ => false,
3596                    })
3597                    .count(),
3598                fission_render::RenderNode::Layer(layer) => layer
3599                    .children
3600                    .iter()
3601                    .map(|child| count_scroll_rails(child, scroll))
3602                    .sum(),
3603            }
3604        }
3605
3606        let rail_count: usize = pipeline
3607            .retained_scene()
3608            .expect("retained scene")
3609            .roots
3610            .iter()
3611            .map(|node| count_scroll_rails(node, scroll))
3612            .sum();
3613        assert!(
3614            rail_count > 0,
3615            "expected an overflow rail for the scroll node"
3616        );
3617    }
3618}