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