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