Skip to main content

fission_test/
lib.rs

1use anyhow::Result;
2use fission_core::lowering::build_layout_tree;
3use fission_core::{
4    Action, ActionEnvelope, ActionId, AdvanceTo, AppState, BuildCtx, Clock, CurrentTime, Env,
5    InputEvent, LayoutPoint, LoweringContext, Runtime, ScrollStateMap, View, Widget,
6};
7use fission_ir::{CoreIR, NodeId};
8use fission_layout::{LayoutEngine, LayoutSize, LayoutSnapshot, TextMeasurer};
9use fission_render::{
10    embed_surface_id, BoxShadow, Color, DisplayList, DisplayOp, LayoutRect, RenderScene, Renderer,
11};
12use fission_render_vello::parley::FontContext;
13use fission_render_vello::VelloTextMeasurer;
14use fission_theme::fonts;
15use fontique::{Blob, Collection, CollectionOptions, FontInfoOverride, SourceCache};
16use std::sync::{Arc, Mutex};
17
18// A mock renderer that captures the display list for inspection.
19#[derive(Default, Clone)]
20pub struct MockRenderer {
21    pub last_display_list: Arc<Mutex<Option<DisplayList>>>,
22}
23
24impl Renderer for MockRenderer {
25    fn render_scene(&mut self, scene: &RenderScene) -> Result<()> {
26        let mut lock = self.last_display_list.lock().unwrap();
27        *lock = Some(scene.flatten());
28        Ok(())
29    }
30}
31
32struct MockTextMeasurer;
33impl TextMeasurer for MockTextMeasurer {
34    fn measure(&self, text: &str, _font_size: f32, avail: Option<f32>) -> (f32, f32) {
35        let char_width = 10.0;
36        let line_height = 20.0;
37        let full_width = text.len() as f32 * char_width;
38
39        if let Some(w) = avail {
40            if full_width > w {
41                // Wrap
42                // Avoid division by zero
43                let safe_w = w.max(char_width);
44                let lines = (full_width / safe_w).ceil();
45                return (w, lines * line_height);
46            }
47        }
48        (full_width, line_height)
49    }
50    fn hit_test(
51        &self,
52        _text: &str,
53        _font_size: f32,
54        _available_width: Option<f32>,
55        _x: f32,
56        _y: f32,
57    ) -> usize {
58        0
59    }
60    fn measure_rich_text(
61        &self,
62        runs: &[fission_ir::op::TextRun],
63        available_width: Option<f32>,
64    ) -> (f32, f32) {
65        let full_w: f32 = runs.iter().map(|r| r.text.len() as f32 * 10.0).sum();
66        let char_width = 10.0;
67        let line_height = 20.0;
68
69        if let Some(w) = available_width {
70            if full_w > w {
71                let safe_w = w.max(char_width);
72                let lines = (full_w / safe_w).ceil();
73                return (w, lines * line_height);
74            }
75        }
76        (full_w.max(10.0), line_height)
77    }
78}
79
80const DEFAULT_TEST_FONT_FAMILY: &str = "Fission Default";
81
82fn should_use_mock_measurer() -> bool {
83    let env_mock = std::env::var("FISSION_TEST_USE_MOCK_MEASURER")
84        .map(|v| {
85            let v = v.to_ascii_lowercase();
86            v == "1" || v == "true" || v == "yes"
87        })
88        .unwrap_or(false);
89    let env_kind = std::env::var("FISSION_TEST_MEASURER")
90        .map(|v| v.to_ascii_lowercase())
91        .ok();
92    env_mock || matches!(env_kind.as_deref(), Some("mock"))
93}
94
95fn build_vello_measurer() -> Arc<dyn TextMeasurer> {
96    let font_cx = Arc::new(Mutex::new(build_font_context()));
97    {
98        let mut font_cx = font_cx.lock().unwrap();
99        let font_data = fonts::default_font_bytes().to_vec();
100        let info_override = FontInfoOverride {
101            family_name: Some(DEFAULT_TEST_FONT_FAMILY),
102            ..Default::default()
103        };
104        font_cx
105            .collection
106            .register_fonts(Blob::from(font_data), Some(info_override));
107    }
108    Arc::new(VelloTextMeasurer::new_with_default_family(
109        font_cx,
110        DEFAULT_TEST_FONT_FAMILY,
111    ))
112}
113
114fn build_font_context() -> FontContext {
115    let use_system_fonts = std::env::var("FISSION_USE_SYSTEM_FONTS")
116        .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
117        .unwrap_or(false);
118    let options = CollectionOptions {
119        shared: false,
120        system_fonts: use_system_fonts,
121    };
122    FontContext {
123        collection: Collection::new(options),
124        source_cache: SourceCache::default(),
125    }
126}
127
128pub mod linter;
129pub use linter::*;
130
131pub mod driver;
132pub use driver::{SemanticMatch, TestDriver, TextMatch};
133
134pub mod prelude {
135    pub use crate::linter::{LayoutLinter, LayoutViolation};
136    pub use crate::{
137        detect_ir_cycle, MockRenderer, SemanticMatch, TestDriver, TestHarness, TextMatch,
138    };
139    pub use fission_ir::semantics::{ActionTrigger, Role};
140    pub use fission_ir::{EmbedKind, LayoutOp, Op, PaintOp};
141    pub use fission_render::{DisplayList, DisplayOp};
142}
143
144pub struct TestHarness<S: AppState> {
145    pub runtime: Runtime,
146    pub renderer: MockRenderer,
147    pub layout_engine: LayoutEngine,
148    pub last_snapshot: Option<LayoutSnapshot>,
149    pub last_ir: Option<CoreIR>,
150    pub root_widget: Option<Box<dyn Widget<S>>>,
151    pub env: Env,
152    pub measurer: Arc<dyn TextMeasurer>,
153    _phantom: std::marker::PhantomData<S>,
154}
155
156impl<S: AppState> TestHarness<S> {
157    // ...
158    pub fn lint(&self) -> Vec<LayoutViolation> {
159        if let (Some(ir), Some(snapshot)) = (&self.last_ir, &self.last_snapshot) {
160            LayoutLinter::new(ir, snapshot).check()
161        } else {
162            vec![]
163        }
164    }
165
166    pub fn new(initial_state: S) -> Self {
167        if should_use_mock_measurer() {
168            return Self::new_with_measurer(initial_state, Arc::new(MockTextMeasurer));
169        }
170        Self::new_with_measurer(initial_state, build_vello_measurer())
171    }
172
173    pub fn new_with_mock_measurer(initial_state: S) -> Self {
174        Self::new_with_measurer(initial_state, Arc::new(MockTextMeasurer))
175    }
176
177    pub fn new_with_measurer(initial_state: S, measurer: Arc<dyn TextMeasurer>) -> Self {
178        let mut runtime = Runtime::default();
179        if std::any::TypeId::of::<S>() != std::any::TypeId::of::<Clock>() {
180            runtime
181                .add_app_state(Box::new(initial_state))
182                .expect("Failed to add initial state");
183        }
184
185        Self {
186            runtime: runtime.with_measurer(measurer.clone()),
187            renderer: MockRenderer::default(),
188            layout_engine: LayoutEngine::new().with_measurer(measurer.clone()),
189            last_snapshot: None,
190            last_ir: None,
191            root_widget: None,
192            env: Env::default(),
193            measurer,
194            _phantom: std::marker::PhantomData,
195        }
196    }
197
198    pub fn with_root_widget<W: Widget<S> + 'static>(mut self, widget: W) -> Self {
199        self.root_widget = Some(Box::new(widget));
200        self
201    }
202
203    pub fn register_reducer(
204        mut self,
205        action_id: ActionId,
206        reducer: fn(&mut S, &ActionEnvelope, NodeId) -> Result<()>,
207    ) -> Self {
208        self.runtime
209            .register_reducer::<S>(action_id, reducer)
210            .unwrap();
211        self
212    }
213
214    pub fn dispatch(&mut self, action: impl Action + 'static) -> Result<()> {
215        let target = NodeId::derived(0, &[0]);
216        let envelope: ActionEnvelope = action.into();
217        self.runtime.dispatch(envelope, target)
218    }
219
220    pub fn send_event(&mut self, event: InputEvent) -> Result<()> {
221        if let (Some(ir), Some(layout)) = (&self.last_ir, &self.last_snapshot) {
222            self.runtime.handle_input(event, ir, layout)
223        } else {
224            anyhow::bail!(
225                "Cannot handle input: no frame pumped (missing IR/Layout). Call pump() first."
226            );
227        }
228    }
229
230    pub fn tick(&mut self, dt: CurrentTime) -> Result<()> {
231        self.runtime.tick(dt).map(|_| ())
232    }
233
234    pub fn advance_to(&mut self, time: CurrentTime) -> Result<()> {
235        self.dispatch(AdvanceTo { time })
236    }
237
238    pub fn current_time(&self) -> CurrentTime {
239        self.runtime.clock().current_time()
240    }
241
242    pub fn pump(&mut self) -> Result<()> {
243        let trace = std::env::var("FISSION_TEST_TRACE").ok().as_deref() == Some("1");
244        let mut viewport = LayoutSize {
245            width: 800.0,
246            height: 600.0,
247        };
248        if self.env.viewport_size.width > 0.0
249            && self.env.viewport_size.height > 0.0
250            && self.env.viewport_size.width.is_finite()
251            && self.env.viewport_size.height.is_finite()
252        {
253            viewport = self.env.viewport_size;
254        } else {
255            self.env.viewport_size = viewport;
256        }
257        // 1. Build & Lower
258        if let Some(root) = &self.root_widget {
259            // Build
260            if trace {
261                eprintln!("[test-trace] build start");
262            }
263            let node_tree = {
264                let state = self
265                    .runtime
266                    .get_app_state::<S>()
267                    .expect("App state missing");
268                let view = View::new(
269                    state,
270                    &self.runtime.runtime_state,
271                    &self.env,
272                    self.last_snapshot.as_ref(),
273                );
274                let mut ctx = BuildCtx::new();
275                let tree = root.build(&mut ctx, &view);
276
277                self.runtime.clear_reducers();
278                let animation_requests = ctx.take_animation_requests();
279                let video_nodes = ctx.take_video_registrations();
280                let web_nodes = ctx.take_web_registrations();
281                let portals_with_ids = ctx.take_portals();
282
283                let portals = portals_with_ids
284                    .into_iter()
285                    .map(|(id, node)| {
286                        if let Some(id) = id {
287                            // Use a derived ID for the wrapper to avoid conflict with the widget's own node
288                            let wrapper_id = NodeId::derived(id.as_u128(), &[0x0000_F001]);
289                            fission_core::ui::Container::new(node)
290                                .id(wrapper_id)
291                                .width(viewport.width)
292                                .height(viewport.height)
293                                .into_node()
294                        } else {
295                            node
296                        }
297                    })
298                    .collect::<Vec<_>>();
299
300                self.runtime.absorb_registry(ctx.registry);
301                for (target, request) in animation_requests {
302                    self.runtime.enqueue_animation(target, request);
303                }
304                self.runtime.sync_video_nodes(&video_nodes);
305                self.runtime.sync_web_nodes(&web_nodes);
306
307                if portals.is_empty() {
308                    tree
309                } else {
310                    // Match the desktop shell overlay composition: always wrap content
311                    // in an Overlay so portals render in a separate AbsoluteFill layer
312                    // and do not participate in normal layout.
313                    fission_core::ui::Node::Overlay(fission_core::ui::Overlay {
314                        id: None,
315                        // Ensure content establishes a viewport-sized containing block so
316                        // the overlay AbsoluteFill has a concrete size in tests.
317                        content: Box::new(
318                            fission_core::ui::Container::new(tree)
319                                .width(viewport.width)
320                                .height(viewport.height)
321                                .into_node(),
322                        ),
323                        overlay: Box::new(fission_core::ui::Node::ZStack(
324                            fission_core::ui::ZStack {
325                                id: None,
326                                children: portals,
327                            },
328                        )),
329                    })
330                }
331            };
332            if trace {
333                eprintln!("[test-trace] build done");
334            }
335
336            // Lower
337            if trace {
338                eprintln!("[test-trace] lower start");
339            }
340            let mut cx = LoweringContext::new(
341                &self.env,
342                &self.runtime.runtime_state,
343                Some(&self.measurer),
344                self.last_snapshot.as_ref(),
345            );
346            let root_id = node_tree.lower(&mut cx);
347            cx.ir.root = Some(root_id);
348
349            let layout_input_nodes = build_layout_tree(&cx.ir, &self.env);
350            self.last_ir = Some(cx.ir);
351            if trace {
352                eprintln!("[test-trace] lower done nodes={}", layout_input_nodes.len());
353            }
354
355            // 2. Layout
356            if trace {
357                eprintln!("[test-trace] layout start");
358            }
359            self.layout_engine.update(&layout_input_nodes);
360            self.layout_engine
361                .verify_post_update(&layout_input_nodes, root_id)?;
362            let snapshot = self.layout_engine.compute_layout(
363                &layout_input_nodes,
364                root_id,
365                viewport,
366                &|id| self.runtime.runtime_state.scroll.get_offset(id),
367            )?;
368            self.last_snapshot = Some(snapshot);
369            if trace {
370                eprintln!("[test-trace] layout done");
371            }
372        } else {
373            return Ok(());
374        }
375
376        // 3. Render
377        if trace {
378            eprintln!("[test-trace] render start");
379        }
380        let mut display_list =
381            DisplayList::new(LayoutRect::new(0.0, 0.0, viewport.width, viewport.height));
382
383        if let (Some(ir), Some(snapshot)) = (&self.last_ir, &self.last_snapshot) {
384            if let Some(root_id) = ir.root {
385                let scroll_map = &self.runtime.runtime_state.scroll;
386                let animation_map = &self.runtime.runtime_state.animation;
387                generate_display_list(
388                    root_id,
389                    ir,
390                    snapshot,
391                    scroll_map,
392                    animation_map,
393                    &mut display_list,
394                );
395            }
396        }
397
398        self.renderer.render(&display_list)?;
399        if trace {
400            eprintln!("[test-trace] render done");
401        }
402
403        Ok(())
404    }
405
406    pub fn get_last_display_list(&self) -> Option<DisplayList> {
407        self.renderer.last_display_list.lock().unwrap().clone()
408    }
409}
410
411pub fn detect_ir_cycle(ir: &CoreIR) -> Option<Vec<NodeId>> {
412    use std::collections::HashSet;
413
414    fn dfs(
415        ir: &CoreIR,
416        node: NodeId,
417        visited: &mut HashSet<NodeId>,
418        stack: &mut HashSet<NodeId>,
419        path: &mut Vec<NodeId>,
420    ) -> Option<Vec<NodeId>> {
421        if !visited.insert(node) {
422            return None;
423        }
424        stack.insert(node);
425        path.push(node);
426        if let Some(n) = ir.nodes.get(&node) {
427            for &child in &n.children {
428                if stack.contains(&child) {
429                    if let Some(pos) = path.iter().position(|&id| id == child) {
430                        return Some(path[pos..].to_vec());
431                    } else {
432                        return Some(vec![child]);
433                    }
434                }
435                if let Some(cy) = dfs(ir, child, visited, stack, path) {
436                    return Some(cy);
437                }
438            }
439        }
440        stack.remove(&node);
441        path.pop();
442        None
443    }
444
445    if let Some(root) = ir.root {
446        let mut visited = HashSet::new();
447        let mut stack = HashSet::new();
448        let mut path = Vec::new();
449        return dfs(ir, root, &mut visited, &mut stack, &mut path);
450    }
451    None
452}
453
454fn map_fill(f: &fission_ir::op::Fill) -> fission_render::Fill {
455    match f {
456        fission_ir::op::Fill::Solid(c) => fission_render::Fill::Solid(fission_render::Color {
457            r: c.r,
458            g: c.g,
459            b: c.b,
460            a: c.a,
461        }),
462        fission_ir::op::Fill::LinearGradient { start, end, stops } => {
463            fission_render::Fill::LinearGradient {
464                start: *start,
465                end: *end,
466                stops: stops
467                    .iter()
468                    .map(|(o, c)| {
469                        (
470                            *o,
471                            fission_render::Color {
472                                r: c.r,
473                                g: c.g,
474                                b: c.b,
475                                a: c.a,
476                            },
477                        )
478                    })
479                    .collect(),
480            }
481        }
482        fission_ir::op::Fill::RadialGradient {
483            center,
484            radius,
485            stops,
486        } => fission_render::Fill::RadialGradient {
487            center: *center,
488            radius: *radius,
489            stops: stops
490                .iter()
491                .map(|(o, c)| {
492                    (
493                        *o,
494                        fission_render::Color {
495                            r: c.r,
496                            g: c.g,
497                            b: c.b,
498                            a: c.a,
499                        },
500                    )
501                })
502                .collect(),
503        },
504    }
505}
506
507fn map_stroke(s: &fission_ir::op::Stroke) -> fission_render::Stroke {
508    fission_render::Stroke {
509        fill: map_fill(&s.fill),
510        width: s.width,
511        dash_array: s.dash_array.clone(),
512        line_cap: match s.line_cap {
513            fission_ir::op::LineCap::Butt => fission_render::LineCap::Butt,
514            fission_ir::op::LineCap::Round => fission_render::LineCap::Round,
515            fission_ir::op::LineCap::Square => fission_render::LineCap::Square,
516        },
517        line_join: match s.line_join {
518            fission_ir::op::LineJoin::Miter => fission_render::LineJoin::Miter,
519            fission_ir::op::LineJoin::Round => fission_render::LineJoin::Round,
520            fission_ir::op::LineJoin::Bevel => fission_render::LineJoin::Bevel,
521        },
522    }
523}
524
525fn generate_display_list(
526    node_id: NodeId,
527    ir: &CoreIR,
528    snapshot: &LayoutSnapshot,
529    scroll_map: &ScrollStateMap,
530    animation_map: &fission_core::env::AnimationStateMap,
531    list: &mut DisplayList,
532) {
533    use std::collections::HashSet;
534    let mut visited = HashSet::new();
535    generate_display_list_with_visited(
536        node_id,
537        ir,
538        snapshot,
539        scroll_map,
540        animation_map,
541        list,
542        &mut visited,
543    );
544}
545
546fn generate_display_list_with_visited(
547    node_id: NodeId,
548    ir: &CoreIR,
549    snapshot: &LayoutSnapshot,
550    scroll_map: &ScrollStateMap,
551    animation_map: &fission_core::env::AnimationStateMap,
552    list: &mut DisplayList,
553    visited: &mut std::collections::HashSet<NodeId>,
554) {
555    if !visited.insert(node_id) {
556        return;
557    }
558    if let Some(geom) = snapshot.nodes.get(&node_id) {
559        if let Some(node) = ir.nodes.get(&node_id) {
560            let mut pushed_state = false;
561            let mut clip_applied = false;
562            let rect = geom.rect;
563
564            let opacity = resolve_composite_scalar(
565                node.composite.opacity.as_ref(),
566                animation_map,
567                fission_core::registry::AnimationPropertyId::Opacity,
568            );
569            let tx = resolve_composite_scalar(
570                node.composite.translate_x.as_ref(),
571                animation_map,
572                fission_core::registry::AnimationPropertyId::TranslateX,
573            )
574            .unwrap_or(0.0);
575            let ty = resolve_composite_scalar(
576                node.composite.translate_y.as_ref(),
577                animation_map,
578                fission_core::registry::AnimationPropertyId::TranslateY,
579            )
580            .unwrap_or(0.0);
581            let scale = resolve_composite_scalar(
582                node.composite.scale.as_ref(),
583                animation_map,
584                fission_core::registry::AnimationPropertyId::Scale,
585            )
586            .unwrap_or(1.0);
587            let rotation = resolve_composite_scalar(
588                node.composite.rotation.as_ref(),
589                animation_map,
590                fission_core::registry::AnimationPropertyId::Rotation,
591            )
592            .unwrap_or(0.0);
593
594            match &node.op {
595                fission_ir::Op::Layout(fission_ir::LayoutOp::Scroll { direction, .. }) => {
596                    let offset = scroll_map.get_offset(node_id);
597                    list.push(DisplayOp::Save);
598                    list.push(DisplayOp::ClipRect(rect));
599                    clip_applied = true;
600                    match direction {
601                        fission_ir::FlexDirection::Row => {
602                            list.push(DisplayOp::Translate(LayoutPoint::new(-offset, 0.0)));
603                        }
604                        fission_ir::FlexDirection::Column => {
605                            list.push(DisplayOp::Translate(LayoutPoint::new(0.0, -offset)));
606                        }
607                    }
608                    pushed_state = true;
609                }
610                fission_ir::Op::Layout(fission_ir::LayoutOp::Clip { .. }) => {
611                    list.push(DisplayOp::Save);
612                    list.push(DisplayOp::ClipRect(rect));
613                    clip_applied = true;
614                    pushed_state = true;
615                }
616                fission_ir::Op::Layout(fission_ir::LayoutOp::Transform { transform }) => {
617                    list.push(DisplayOp::Save);
618                    list.push(DisplayOp::Transform(*transform));
619                    pushed_state = true;
620                }
621                _ => {}
622            }
623
624            if node.composite.clip_to_bounds && !clip_applied {
625                if !pushed_state {
626                    list.push(DisplayOp::Save);
627                    pushed_state = true;
628                }
629                list.push(DisplayOp::ClipRect(rect));
630            }
631
632            if let Some(opacity) = opacity {
633                if (opacity - 1.0).abs() > 0.001 {
634                    if !pushed_state {
635                        list.push(DisplayOp::Save);
636                        pushed_state = true;
637                    }
638                    list.push(DisplayOp::OpacityLayer {
639                        alpha: opacity,
640                        bounds: rect,
641                    });
642                }
643            }
644
645            if tx.abs() > 0.001
646                || ty.abs() > 0.001
647                || (scale - 1.0).abs() > 0.001
648                || rotation.abs() > 0.001
649            {
650                if !pushed_state {
651                    list.push(DisplayOp::Save);
652                    pushed_state = true;
653                }
654                list.push(DisplayOp::Transform(composite_transform_matrix(
655                    rect, tx, ty, scale, rotation,
656                )));
657            }
658
659            match &node.op {
660                fission_ir::Op::Paint(fission_ir::PaintOp::DrawRect {
661                    fill,
662                    stroke,
663                    corner_radius,
664                    shadow,
665                }) => {
666                    list.push(DisplayOp::DrawRect {
667                        rect: geom.rect,
668                        fill: fill.as_ref().map(map_fill),
669                        stroke: stroke.as_ref().map(map_stroke),
670                        corner_radius: *corner_radius,
671                        shadow: shadow.map(|s| BoxShadow {
672                            color: Color {
673                                r: s.color.r,
674                                g: s.color.g,
675                                b: s.color.b,
676                                a: s.color.a,
677                            },
678                            blur_radius: s.blur_radius,
679                            offset: s.offset,
680                        }),
681                        bounds: geom.rect,
682                        node_id: Some(node_id),
683                    });
684                }
685                fission_ir::Op::Paint(fission_ir::PaintOp::DrawText {
686                    text,
687                    size,
688                    color,
689                    underline,
690                    wrap,
691                    caret_index,
692                    caret_color,
693                    caret_width,
694                    caret_height,
695                    caret_radius,
696                    paragraph_style,
697                }) => {
698                    list.push(DisplayOp::DrawText {
699                        text: text.clone(),
700                        position: LayoutPoint::new(geom.rect.x(), geom.rect.y()),
701                        size: *size,
702                        color: fission_render::Color {
703                            r: color.r,
704                            g: color.g,
705                            b: color.b,
706                            a: color.a,
707                        },
708                        bounds: geom.rect,
709                        node_id: Some(node_id),
710                        underline: *underline,
711                        wrap: *wrap,
712                        caret_index: *caret_index,
713                        caret_color: caret_color.map(|color| fission_render::Color {
714                            r: color.r,
715                            g: color.g,
716                            b: color.b,
717                            a: color.a,
718                        }),
719                        caret_width: *caret_width,
720                        caret_height: *caret_height,
721                        caret_radius: *caret_radius,
722                        paragraph_style: *paragraph_style,
723                    });
724                }
725                fission_ir::Op::Paint(fission_ir::PaintOp::DrawRichText {
726                    runs,
727                    wrap,
728                    caret_index,
729                    caret_color,
730                    caret_width,
731                    caret_height,
732                    caret_radius,
733                    paragraph_style,
734                }) => {
735                    let annotations = ir
736                        .custom_render_objects
737                        .get(&node_id)
738                        .and_then(|sidecar| {
739                            sidecar.downcast_ref::<Vec<fission_ir::op::RichTextAnnotation>>()
740                        })
741                        .cloned()
742                        .unwrap_or_default();
743                    list.push(DisplayOp::DrawRichText {
744                        runs: runs
745                            .iter()
746                            .map(|r| fission_render::TextRun {
747                                text: r.text.clone(),
748                                style: fission_render::TextStyle {
749                                    font_size: r.style.font_size,
750                                    color: fission_render::Color {
751                                        r: r.style.color.r,
752                                        g: r.style.color.g,
753                                        b: r.style.color.b,
754                                        a: r.style.color.a,
755                                    },
756                                    underline: r.style.underline,
757                                    font_family: r.style.font_family.clone(),
758                                    locale: r.style.locale.clone(),
759                                    font_weight: r.style.font_weight,
760                                    font_style: r.style.font_style,
761                                    line_height: r.style.line_height,
762                                    letter_spacing: r.style.letter_spacing,
763                                    background_color: r.style.background_color.map(|c| {
764                                        fission_render::Color {
765                                            r: c.r,
766                                            g: c.g,
767                                            b: c.b,
768                                            a: c.a,
769                                        }
770                                    }),
771                                },
772                            })
773                            .collect(),
774                        position: LayoutPoint::new(geom.rect.x(), geom.rect.y()),
775                        bounds: geom.rect,
776                        node_id: Some(node_id),
777                        wrap: *wrap,
778                        caret_index: *caret_index,
779                        caret_color: caret_color.map(|color| fission_render::Color {
780                            r: color.r,
781                            g: color.g,
782                            b: color.b,
783                            a: color.a,
784                        }),
785                        caret_width: *caret_width,
786                        caret_height: *caret_height,
787                        caret_radius: *caret_radius,
788                        paragraph_style: *paragraph_style,
789                        annotations,
790                    });
791                }
792                fission_ir::Op::Paint(fission_ir::PaintOp::DrawSvg {
793                    content,
794                    fill,
795                    stroke,
796                }) => {
797                    list.push(DisplayOp::DrawSvg {
798                        content: content.clone(),
799                        fill: fill.as_ref().map(map_fill),
800                        stroke: stroke.as_ref().map(map_stroke),
801                        bounds: geom.rect,
802                        node_id: Some(node_id),
803                    });
804                }
805                fission_ir::Op::Paint(fission_ir::PaintOp::DrawImage { source, fit }) => {
806                    list.push(DisplayOp::DrawImage {
807                        rect: geom.rect,
808                        source: source.clone(),
809                        fit: match fit {
810                            fission_ir::op::ImageFit::Contain => fission_render::ImageFit::Contain,
811                            fission_ir::op::ImageFit::Cover => fission_render::ImageFit::Cover,
812                            fission_ir::op::ImageFit::Fill => fission_render::ImageFit::Fill,
813                            fission_ir::op::ImageFit::None => fission_render::ImageFit::None,
814                        },
815                        bounds: geom.rect,
816                        node_id: Some(node_id),
817                    });
818                }
819                fission_ir::Op::Paint(fission_ir::PaintOp::DrawPath { path, fill, stroke }) => {
820                    list.push(DisplayOp::DrawPath {
821                        path: path.clone(),
822                        fill: fill.as_ref().map(map_fill),
823                        stroke: stroke.as_ref().map(map_stroke),
824                        bounds: geom.rect,
825                        node_id: Some(node_id),
826                    });
827                }
828                fission_ir::Op::Layout(fission_ir::LayoutOp::Embed {
829                    kind, widget_id, ..
830                }) => {
831                    list.push(DisplayOp::DrawSurface {
832                        rect: geom.rect,
833                        surface_id: embed_surface_id(kind, *widget_id),
834                        position: 0,
835                        bounds: geom.rect,
836                        node_id: Some(node_id),
837                    });
838                }
839                _ => {}
840            }
841
842            for child in &node.children {
843                generate_display_list_with_visited(
844                    *child,
845                    ir,
846                    snapshot,
847                    scroll_map,
848                    animation_map,
849                    list,
850                    visited,
851                );
852            }
853
854            if pushed_state {
855                list.push(DisplayOp::Restore);
856            }
857        }
858    }
859}
860
861fn resolve_composite_scalar(
862    scalar: Option<&fission_ir::CompositeScalar>,
863    animation_map: &fission_core::env::AnimationStateMap,
864    property: fission_core::registry::AnimationPropertyId,
865) -> Option<f32> {
866    let scalar = scalar?;
867    Some(
868        scalar
869            .animation_target
870            .and_then(|target| animation_map.values.get(&(target, property)).copied())
871            .unwrap_or(scalar.base),
872    )
873}
874
875fn composite_transform_matrix(
876    rect: LayoutRect,
877    translate_x: f32,
878    translate_y: f32,
879    scale: f32,
880    rotation: f32,
881) -> [f32; 16] {
882    let center_x = rect.origin.x + rect.size.width * 0.5;
883    let center_y = rect.origin.y + rect.size.height * 0.5;
884
885    let to_center = translation_matrix(center_x, center_y);
886    let from_center = translation_matrix(-center_x, -center_y);
887    let scale_matrix = scale_matrix(scale);
888    let rotation_matrix = rotation_z_matrix(rotation);
889    let animated_translate = translation_matrix(translate_x, translate_y);
890
891    multiply_matrix(
892        animated_translate,
893        multiply_matrix(
894            to_center,
895            multiply_matrix(rotation_matrix, multiply_matrix(scale_matrix, from_center)),
896        ),
897    )
898}
899
900fn translation_matrix(tx: f32, ty: f32) -> [f32; 16] {
901    [
902        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,
903    ]
904}
905
906fn scale_matrix(scale: f32) -> [f32; 16] {
907    [
908        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,
909    ]
910}
911
912fn rotation_z_matrix(radians: f32) -> [f32; 16] {
913    let sin = radians.sin();
914    let cos = radians.cos();
915    [
916        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,
917    ]
918}
919
920fn multiply_matrix(a: [f32; 16], b: [f32; 16]) -> [f32; 16] {
921    let mut out = [0.0; 16];
922    for row in 0..4 {
923        for col in 0..4 {
924            let mut sum = 0.0;
925            for k in 0..4 {
926                sum += a[row * 4 + k] * b[k * 4 + col];
927            }
928            out[row * 4 + col] = sum;
929        }
930    }
931    out
932}