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 animation_requests = ctx.take_animation_requests();
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                for (target, request) in animation_requests {
310                    self.runtime.enqueue_animation(target, request);
311                }
312                self.runtime.sync_video_nodes(&video_nodes);
313                self.runtime.sync_web_nodes(&web_nodes);
314
315                if portals.is_empty() {
316                    tree
317                } else {
318                    // Match the desktop shell overlay composition: always wrap content
319                    // in an Overlay so portals render in a separate AbsoluteFill layer
320                    // and do not participate in normal layout.
321                    fission_core::ui::Overlay {
322                        id: None,
323                        // Ensure content establishes a viewport-sized containing block so
324                        // the overlay AbsoluteFill has a concrete size in tests.
325                        content: fission_core::ui::Container::new(tree)
326                            .width(viewport.width)
327                            .height(viewport.height)
328                            .into(),
329                        overlay: fission_core::ui::ZStack {
330                            id: None,
331                            children: portals,
332                        }
333                        .into(),
334                    }
335                    .into()
336                }
337            };
338            if trace {
339                eprintln!("[test-trace] build done");
340            }
341
342            // InternalLower
343            if trace {
344                eprintln!("[test-trace] lower start");
345            }
346            let mut cx = InternalLoweringCx::new(
347                &self.env,
348                &self.runtime.runtime_state,
349                Some(&self.measurer),
350                self.last_snapshot.as_ref(),
351            );
352            let root_id = fission_core::internal::lower_widget(&node_tree, &mut cx);
353            cx.ir.root = Some(root_id);
354
355            let layout_input_nodes = build_layout_tree(&cx.ir, &self.env);
356            self.last_ir = Some(cx.ir);
357            if trace {
358                eprintln!("[test-trace] lower done nodes={}", layout_input_nodes.len());
359            }
360
361            // 2. Layout
362            if trace {
363                eprintln!("[test-trace] layout start");
364            }
365            self.layout_engine.update(&layout_input_nodes);
366            self.layout_engine
367                .verify_post_update(&layout_input_nodes, root_id)?;
368            let snapshot = self.layout_engine.compute_layout(
369                &layout_input_nodes,
370                root_id,
371                viewport,
372                &|id| self.runtime.runtime_state.scroll.get_offset(id),
373            )?;
374            self.last_snapshot = Some(snapshot);
375            if trace {
376                eprintln!("[test-trace] layout done");
377            }
378        } else {
379            return Ok(());
380        }
381
382        // 3. Render
383        if trace {
384            eprintln!("[test-trace] render start");
385        }
386        let mut display_list =
387            DisplayList::new(LayoutRect::new(0.0, 0.0, viewport.width, viewport.height));
388
389        if let (Some(ir), Some(snapshot)) = (&self.last_ir, &self.last_snapshot) {
390            if let Some(root_id) = ir.root {
391                let scroll_map = &self.runtime.runtime_state.scroll;
392                let animation_map = &self.runtime.runtime_state.animation;
393                generate_display_list(
394                    root_id,
395                    ir,
396                    snapshot,
397                    scroll_map,
398                    animation_map,
399                    &mut display_list,
400                );
401            }
402        }
403
404        self.renderer.render(&display_list)?;
405        if trace {
406            eprintln!("[test-trace] render done");
407        }
408
409        Ok(())
410    }
411
412    pub fn get_last_display_list(&self) -> Option<DisplayList> {
413        self.renderer.last_display_list.lock().unwrap().clone()
414    }
415}
416
417pub fn detect_ir_cycle(ir: &CoreIR) -> Option<Vec<WidgetId>> {
418    use std::collections::HashSet;
419
420    fn dfs(
421        ir: &CoreIR,
422        node: WidgetId,
423        visited: &mut HashSet<WidgetId>,
424        stack: &mut HashSet<WidgetId>,
425        path: &mut Vec<WidgetId>,
426    ) -> Option<Vec<WidgetId>> {
427        if !visited.insert(node) {
428            return None;
429        }
430        stack.insert(node);
431        path.push(node);
432        if let Some(n) = ir.nodes.get(&node) {
433            for &child in &n.children {
434                if stack.contains(&child) {
435                    if let Some(pos) = path.iter().position(|&id| id == child) {
436                        return Some(path[pos..].to_vec());
437                    } else {
438                        return Some(vec![child]);
439                    }
440                }
441                if let Some(cy) = dfs(ir, child, visited, stack, path) {
442                    return Some(cy);
443                }
444            }
445        }
446        stack.remove(&node);
447        path.pop();
448        None
449    }
450
451    if let Some(root) = ir.root {
452        let mut visited = HashSet::new();
453        let mut stack = HashSet::new();
454        let mut path = Vec::new();
455        return dfs(ir, root, &mut visited, &mut stack, &mut path);
456    }
457    None
458}
459
460fn map_fill(f: &fission_ir::op::Fill) -> fission_render::Fill {
461    match f {
462        fission_ir::op::Fill::Solid(c) => fission_render::Fill::Solid(fission_render::Color {
463            r: c.r,
464            g: c.g,
465            b: c.b,
466            a: c.a,
467        }),
468        fission_ir::op::Fill::LinearGradient { start, end, stops } => {
469            fission_render::Fill::LinearGradient {
470                start: *start,
471                end: *end,
472                stops: stops
473                    .iter()
474                    .map(|(o, c)| {
475                        (
476                            *o,
477                            fission_render::Color {
478                                r: c.r,
479                                g: c.g,
480                                b: c.b,
481                                a: c.a,
482                            },
483                        )
484                    })
485                    .collect(),
486            }
487        }
488        fission_ir::op::Fill::RadialGradient {
489            center,
490            radius,
491            stops,
492        } => fission_render::Fill::RadialGradient {
493            center: *center,
494            radius: *radius,
495            stops: stops
496                .iter()
497                .map(|(o, c)| {
498                    (
499                        *o,
500                        fission_render::Color {
501                            r: c.r,
502                            g: c.g,
503                            b: c.b,
504                            a: c.a,
505                        },
506                    )
507                })
508                .collect(),
509        },
510    }
511}
512
513fn map_stroke(s: &fission_ir::op::Stroke) -> fission_render::Stroke {
514    fission_render::Stroke {
515        fill: map_fill(&s.fill),
516        width: s.width,
517        dash_array: s.dash_array.clone(),
518        line_cap: match s.line_cap {
519            fission_ir::op::LineCap::Butt => fission_render::LineCap::Butt,
520            fission_ir::op::LineCap::Round => fission_render::LineCap::Round,
521            fission_ir::op::LineCap::Square => fission_render::LineCap::Square,
522        },
523        line_join: match s.line_join {
524            fission_ir::op::LineJoin::Miter => fission_render::LineJoin::Miter,
525            fission_ir::op::LineJoin::Round => fission_render::LineJoin::Round,
526            fission_ir::op::LineJoin::Bevel => fission_render::LineJoin::Bevel,
527        },
528    }
529}
530
531fn generate_display_list(
532    node_id: WidgetId,
533    ir: &CoreIR,
534    snapshot: &LayoutSnapshot,
535    scroll_map: &ScrollStateMap,
536    animation_map: &fission_core::env::AnimationStateMap,
537    list: &mut DisplayList,
538) {
539    use std::collections::HashSet;
540    let mut visited = HashSet::new();
541    generate_display_list_with_visited(
542        node_id,
543        ir,
544        snapshot,
545        scroll_map,
546        animation_map,
547        list,
548        &mut visited,
549    );
550}
551
552fn generate_display_list_with_visited(
553    node_id: WidgetId,
554    ir: &CoreIR,
555    snapshot: &LayoutSnapshot,
556    scroll_map: &ScrollStateMap,
557    animation_map: &fission_core::env::AnimationStateMap,
558    list: &mut DisplayList,
559    visited: &mut std::collections::HashSet<WidgetId>,
560) {
561    if !visited.insert(node_id) {
562        return;
563    }
564    if let Some(geom) = snapshot.nodes.get(&node_id) {
565        if let Some(node) = ir.nodes.get(&node_id) {
566            let mut pushed_state = false;
567            let mut clip_applied = false;
568            let rect = geom.rect;
569
570            let opacity = resolve_composite_scalar(
571                node.composite.opacity.as_ref(),
572                animation_map,
573                fission_core::registry::AnimationPropertyId::Opacity,
574            );
575            let tx = resolve_composite_scalar(
576                node.composite.translate_x.as_ref(),
577                animation_map,
578                fission_core::registry::AnimationPropertyId::TranslateX,
579            )
580            .unwrap_or(0.0);
581            let ty = resolve_composite_scalar(
582                node.composite.translate_y.as_ref(),
583                animation_map,
584                fission_core::registry::AnimationPropertyId::TranslateY,
585            )
586            .unwrap_or(0.0);
587            let scale = resolve_composite_scalar(
588                node.composite.scale.as_ref(),
589                animation_map,
590                fission_core::registry::AnimationPropertyId::Scale,
591            )
592            .unwrap_or(1.0);
593            let rotation = resolve_composite_scalar(
594                node.composite.rotation.as_ref(),
595                animation_map,
596                fission_core::registry::AnimationPropertyId::Rotation,
597            )
598            .unwrap_or(0.0);
599
600            match &node.op {
601                fission_ir::Op::Layout(fission_ir::LayoutOp::Scroll { direction, .. }) => {
602                    let offset = scroll_map.get_offset(node_id);
603                    list.push(DisplayOp::Save);
604                    list.push(DisplayOp::ClipRect(rect));
605                    clip_applied = true;
606                    match direction {
607                        fission_ir::FlexDirection::Row => {
608                            list.push(DisplayOp::Translate(LayoutPoint::new(-offset, 0.0)));
609                        }
610                        fission_ir::FlexDirection::Column => {
611                            list.push(DisplayOp::Translate(LayoutPoint::new(0.0, -offset)));
612                        }
613                    }
614                    pushed_state = true;
615                }
616                fission_ir::Op::Layout(fission_ir::LayoutOp::Clip { .. }) => {
617                    list.push(DisplayOp::Save);
618                    list.push(DisplayOp::ClipRect(rect));
619                    clip_applied = true;
620                    pushed_state = true;
621                }
622                fission_ir::Op::Layout(fission_ir::LayoutOp::Transform { transform }) => {
623                    list.push(DisplayOp::Save);
624                    list.push(DisplayOp::Transform(*transform));
625                    pushed_state = true;
626                }
627                _ => {}
628            }
629
630            if node.composite.clip_to_bounds && !clip_applied {
631                if !pushed_state {
632                    list.push(DisplayOp::Save);
633                    pushed_state = true;
634                }
635                list.push(DisplayOp::ClipRect(rect));
636            }
637
638            if let Some(opacity) = opacity {
639                if (opacity - 1.0).abs() > 0.001 {
640                    if !pushed_state {
641                        list.push(DisplayOp::Save);
642                        pushed_state = true;
643                    }
644                    list.push(DisplayOp::OpacityLayer {
645                        alpha: opacity,
646                        bounds: rect,
647                    });
648                }
649            }
650
651            if tx.abs() > 0.001
652                || ty.abs() > 0.001
653                || (scale - 1.0).abs() > 0.001
654                || rotation.abs() > 0.001
655            {
656                if !pushed_state {
657                    list.push(DisplayOp::Save);
658                    pushed_state = true;
659                }
660                list.push(DisplayOp::Transform(composite_transform_matrix(
661                    rect, tx, ty, scale, rotation,
662                )));
663            }
664
665            match &node.op {
666                fission_ir::Op::Paint(fission_ir::PaintOp::DrawRect {
667                    fill,
668                    stroke,
669                    corner_radius,
670                    shadow,
671                }) => {
672                    list.push(DisplayOp::DrawRect {
673                        rect: geom.rect,
674                        fill: fill.as_ref().map(map_fill),
675                        stroke: stroke.as_ref().map(map_stroke),
676                        corner_radius: *corner_radius,
677                        shadow: shadow.map(|s| BoxShadow {
678                            color: Color {
679                                r: s.color.r,
680                                g: s.color.g,
681                                b: s.color.b,
682                                a: s.color.a,
683                            },
684                            blur_radius: s.blur_radius,
685                            offset: s.offset,
686                        }),
687                        bounds: geom.rect,
688                        node_id: Some(node_id),
689                    });
690                }
691                fission_ir::Op::Paint(fission_ir::PaintOp::DrawText {
692                    text,
693                    size,
694                    color,
695                    underline,
696                    wrap,
697                    caret_index,
698                    caret_color,
699                    caret_width,
700                    caret_height,
701                    caret_radius,
702                    paragraph_style,
703                }) => {
704                    list.push(DisplayOp::DrawText {
705                        text: text.clone(),
706                        position: LayoutPoint::new(geom.rect.x(), geom.rect.y()),
707                        size: *size,
708                        color: fission_render::Color {
709                            r: color.r,
710                            g: color.g,
711                            b: color.b,
712                            a: color.a,
713                        },
714                        bounds: geom.rect,
715                        node_id: Some(node_id),
716                        underline: *underline,
717                        wrap: *wrap,
718                        caret_index: *caret_index,
719                        caret_color: caret_color.map(|color| fission_render::Color {
720                            r: color.r,
721                            g: color.g,
722                            b: color.b,
723                            a: color.a,
724                        }),
725                        caret_width: *caret_width,
726                        caret_height: *caret_height,
727                        caret_radius: *caret_radius,
728                        paragraph_style: *paragraph_style,
729                    });
730                }
731                fission_ir::Op::Paint(fission_ir::PaintOp::DrawRichText {
732                    runs,
733                    wrap,
734                    caret_index,
735                    caret_color,
736                    caret_width,
737                    caret_height,
738                    caret_radius,
739                    paragraph_style,
740                }) => {
741                    let annotations = ir
742                        .custom_render_objects
743                        .get(&node_id)
744                        .and_then(|sidecar| {
745                            sidecar.downcast_ref::<Vec<fission_ir::op::RichTextAnnotation>>()
746                        })
747                        .cloned()
748                        .unwrap_or_default();
749                    list.push(DisplayOp::DrawRichText {
750                        runs: runs
751                            .iter()
752                            .map(|r| fission_render::TextRun {
753                                text: r.text.clone(),
754                                style: fission_render::TextStyle {
755                                    font_size: r.style.font_size,
756                                    color: fission_render::Color {
757                                        r: r.style.color.r,
758                                        g: r.style.color.g,
759                                        b: r.style.color.b,
760                                        a: r.style.color.a,
761                                    },
762                                    underline: r.style.underline,
763                                    font_family: r.style.font_family.clone(),
764                                    locale: r.style.locale.clone(),
765                                    font_weight: r.style.font_weight,
766                                    font_style: r.style.font_style,
767                                    line_height: r.style.line_height,
768                                    letter_spacing: r.style.letter_spacing,
769                                    background_color: r.style.background_color.map(|c| {
770                                        fission_render::Color {
771                                            r: c.r,
772                                            g: c.g,
773                                            b: c.b,
774                                            a: c.a,
775                                        }
776                                    }),
777                                },
778                            })
779                            .collect(),
780                        position: LayoutPoint::new(geom.rect.x(), geom.rect.y()),
781                        bounds: geom.rect,
782                        node_id: Some(node_id),
783                        wrap: *wrap,
784                        caret_index: *caret_index,
785                        caret_color: caret_color.map(|color| fission_render::Color {
786                            r: color.r,
787                            g: color.g,
788                            b: color.b,
789                            a: color.a,
790                        }),
791                        caret_width: *caret_width,
792                        caret_height: *caret_height,
793                        caret_radius: *caret_radius,
794                        paragraph_style: *paragraph_style,
795                        annotations,
796                    });
797                }
798                fission_ir::Op::Paint(fission_ir::PaintOp::DrawSvg {
799                    content,
800                    fill,
801                    stroke,
802                }) => {
803                    list.push(DisplayOp::DrawSvg {
804                        content: content.clone(),
805                        fill: fill.as_ref().map(map_fill),
806                        stroke: stroke.as_ref().map(map_stroke),
807                        bounds: geom.rect,
808                        node_id: Some(node_id),
809                    });
810                }
811                fission_ir::Op::Paint(fission_ir::PaintOp::DrawImage {
812                    request,
813                    fit,
814                    alignment,
815                }) => {
816                    list.push(DisplayOp::DrawImage {
817                        rect: geom.rect,
818                        request: request.clone(),
819                        fit: match fit {
820                            fission_ir::op::ImageFit::Contain => fission_render::ImageFit::Contain,
821                            fission_ir::op::ImageFit::Cover => fission_render::ImageFit::Cover,
822                            fission_ir::op::ImageFit::Fill => fission_render::ImageFit::Fill,
823                            fission_ir::op::ImageFit::None => fission_render::ImageFit::None,
824                        },
825                        alignment: *alignment,
826                        bounds: geom.rect,
827                        node_id: Some(node_id),
828                    });
829                }
830                fission_ir::Op::Paint(fission_ir::PaintOp::DrawPath { path, fill, stroke }) => {
831                    list.push(DisplayOp::DrawPath {
832                        path: path.clone(),
833                        fill: fill.as_ref().map(map_fill),
834                        stroke: stroke.as_ref().map(map_stroke),
835                        bounds: geom.rect,
836                        node_id: Some(node_id),
837                    });
838                }
839                fission_ir::Op::Layout(fission_ir::LayoutOp::Embed {
840                    kind, widget_id, ..
841                }) => {
842                    list.push(DisplayOp::DrawSurface {
843                        rect: geom.rect,
844                        surface_id: embed_surface_id(kind, *widget_id),
845                        position: 0,
846                        bounds: geom.rect,
847                        node_id: Some(node_id),
848                    });
849                }
850                _ => {}
851            }
852
853            for child in &node.children {
854                generate_display_list_with_visited(
855                    *child,
856                    ir,
857                    snapshot,
858                    scroll_map,
859                    animation_map,
860                    list,
861                    visited,
862                );
863            }
864
865            if pushed_state {
866                list.push(DisplayOp::Restore);
867            }
868        }
869    }
870}
871
872fn resolve_composite_scalar(
873    scalar: Option<&fission_ir::CompositeScalar>,
874    animation_map: &fission_core::env::AnimationStateMap,
875    property: fission_core::registry::AnimationPropertyId,
876) -> Option<f32> {
877    let scalar = scalar?;
878    Some(
879        scalar
880            .animation_target
881            .and_then(|target| animation_map.values.get(&(target, property)).copied())
882            .unwrap_or(scalar.base),
883    )
884}
885
886fn composite_transform_matrix(
887    rect: LayoutRect,
888    translate_x: f32,
889    translate_y: f32,
890    scale: f32,
891    rotation: f32,
892) -> [f32; 16] {
893    let center_x = rect.origin.x + rect.size.width * 0.5;
894    let center_y = rect.origin.y + rect.size.height * 0.5;
895
896    let to_center = translation_matrix(center_x, center_y);
897    let from_center = translation_matrix(-center_x, -center_y);
898    let scale_matrix = scale_matrix(scale);
899    let rotation_matrix = rotation_z_matrix(rotation);
900    let animated_translate = translation_matrix(translate_x, translate_y);
901
902    multiply_matrix(
903        animated_translate,
904        multiply_matrix(
905            to_center,
906            multiply_matrix(rotation_matrix, multiply_matrix(scale_matrix, from_center)),
907        ),
908    )
909}
910
911fn translation_matrix(tx: f32, ty: f32) -> [f32; 16] {
912    [
913        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,
914    ]
915}
916
917fn scale_matrix(scale: f32) -> [f32; 16] {
918    [
919        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,
920    ]
921}
922
923fn rotation_z_matrix(radians: f32) -> [f32; 16] {
924    let sin = radians.sin();
925    let cos = radians.cos();
926    [
927        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,
928    ]
929}
930
931fn multiply_matrix(a: [f32; 16], b: [f32; 16]) -> [f32; 16] {
932    let mut out = [0.0; 16];
933    for row in 0..4 {
934        for col in 0..4 {
935            let mut sum = 0.0;
936            for k in 0..4 {
937                sum += a[row * 4 + k] * b[k * 4 + col];
938            }
939            out[row * 4 + col] = sum;
940        }
941    }
942    out
943}