Skip to main content

fission_test/
lib.rs

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